[
  {
    "path": ".distignore",
    "content": "/.wordpress-org\n/.git\n/.github\n/node_modules\n\n.distignore\n.gitignore\n\n/.build\n.php_cs.dist\ndeploy_key.enc\nmix-manifest.json\npackage-lock.json\npackage.json\n*.code-workspace\nwebpack.mix.js\n/vendor/twig/twig\n\n*.swp\n"
  },
  {
    "path": ".dockerignore",
    "content": "!/dist\nvendor\nvendor-prefixed\n"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# editorconfig.org\n\nroot = true\n\n\n[*]\n\n# Change these settings to your own preference\nindent_style = space\nindent_size = 2\nmax_line_length = 120\n\n# We recommend you to keep these unchanged\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[*.html]\nindent_size = 2\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "Thanks for reading our contribution guidelines!\n\n* [Report a Bug](#report-bug)\n* [Ask for Support](#request-support)\n* [Theme Incompatibility](#theme-compat)\n* [Help / Donate](#donate)\n\n<a name=“report-bug”></a>\n# Report a Bug\n\nSomething is not working?\nPlease follow the steps below to help us isolate the cause of error.\n\n### Disable Podlove Cache\n\nWhile testing, disable our internal cache. Put the following at the end of `wp-config.php`\n\n```php\n# wp-config.php\ndefine('PODLOVE_TEMPLATE_CACHE', false);\n```\n\n### Disable other Caches\n\nIf you are using a caching plugin, please deactivate it. Examples for such plugins are:\n\n- W3 Total Cache\n- WP Super Cache\n- Quick Cache\n\n### Does it work when you use a default theme (like “twentyfifteen”)?\n\nSometimes themes change default WordPress behavior that breaks plugins. By testing your setup with a default theme, we can make sure it's not the themes fault.\n\n### Does it work when you disable all plugins except the Publisher?\n\nJust like the theme, other plugins might interfere with how the Publisher works.\n\n### Now What?\n\nYou followed the steps above and the error still persists?\nCreate a [GitHub Issue](https://github.com/podlove/podlove-publisher/issues) if you haven't done so already, paste the output from your `Podlove ➜ Support` menu and mention that you have followed the steps above.\n\nThank you!\n\n<a name=“request-support”></a>\n# Ask for Support\n\nWe have a community forum for questions, answers and feature discussions at [community.podlove.org](https://community.podlove.org).\n\nPlease check if your questions are answered in our growing documentation site [docs.podlove.org](http://docs.podlove.org). If you still have open questions, feel free to open a support issue.\n\n<a name=\"theme-compat\"></a>\n# Theme Incompatibility\n\nUnfortunately, many themes are incompatible with [Custom Post Types](https://codex.wordpress.org/Post_Types), which the Podlove Publisher uses for episodes. When you encounter problems, **please go to the theme support first**.\n\nOnly if you are sure you encountered a theme-related bug in the Podlove Publisher, post here. Otherwise, ask for help in our [community](https://community.podlove.org).\n\n<a name=“donate”></a>\n# Donate\n\nWe are happy about every donation. Please visit [podlove.org/donations](http://podlove.org/donations/) for details.\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "open_collective: podlove\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "### Expected behavior\n\n### Actual behavior\n\n### System information (see `Podlove > Support` menu)\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages\nname: Create and publish a Docker image\n\n# Configures this workflow to run every time a change is pushed to the branch called `release`.\non:\n  push:\n    branches: ['beta', 'master']\n\n# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\n# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.\n    permissions:\n      contents: read\n      packages: write\n      attestations: write\n      id-token: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install devbox\n        uses: jetify-com/devbox-install-action@v0.11.0\n\n      - name: Bootstrap Podlove Publisher\n        run: devbox run bootstrap\n\n      - name: Build Podlove Publisher\n        run: devbox run build\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3.2.0\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5.5.1\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build and push Docker image\n        id: push\n        uses: docker/build-push-action@v6.1.0\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n\n"
  },
  {
    "path": ".github/workflows/release-beta.yml",
    "content": "on:\n  push:\n    # Sequence of patterns matched against refs/tags\n    tags:\n      - '*-beta*'\n\nname: Beta Release\n\njobs:\n  build:\n    name: Build and Release Beta Version\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Setup PHP with PECL extension\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.0'\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Build project\n        env:\n          TAG_NAME: ${{ github.ref }}\n        run: |\n          make install_php_scoper\n          make build\n          mv dist podlove-podcasting-plugin-for-wordpress\n          zip -r podlove-podcasting-plugin-for-wordpress.zip podlove-podcasting-plugin-for-wordpress\n      - name: Create Release\n        id: create_release\n        uses: softprops/action-gh-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref }}\n          name: ${{ github.ref }}\n          draft: false\n          prerelease: false\n          files: |\n            podlove-podcasting-plugin-for-wordpress.zip\n"
  },
  {
    "path": ".github/workflows/release-wordpress.yml",
    "content": "name: Release to WordPress.org\non:\n  push:\n    tags:\n      - '*'\n      - '!*-beta*'\njobs:\n  tag:\n    name: Build and Release to WordPress.org\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Setup PHP with PECL extension\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.0'\n      - uses: actions/checkout@v3\n      - name: Build\n        run: |\n          make install_php_scoper\n          make build\n          npm install fs-extra\n          node bin/workspace.js\n      - name: WordPress Plugin Deploy\n        uses: 10up/action-wordpress-plugin-deploy@master\n        env:\n          SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}\n          SVN_USERNAME: ${{ secrets.SVN_USERNAME }}\n          SLUG: podlove-podcasting-plugin-for-wordpress\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  pull_request:\n\njobs:\n  phpunit:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.0'\n          extensions: mbstring, xml\n\n      - name: Set up Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Cache wp-env\n        uses: actions/cache@v4\n        with:\n          path: ~/.wp-env\n          key: ${{ runner.os }}-wp-env-${{ hashFiles('.wp-env.test.json') }}\n\n      - name: Cache Composer\n        uses: actions/cache@v4\n        with:\n          path: ~/.composer/cache\n          key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}\n\n      - name: Cache npm\n        uses: actions/cache@v4\n        with:\n          path: ~/.npm\n          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}\n\n      - name: Install PHP dependencies (with prefixing)\n        run: make install\n\n      - name: Install PHP dev dependencies (phpunit)\n        run: composer install --no-interaction --prefer-dist\n\n      - name: Install Node dependencies\n        run: npm install\n\n      - name: Start wp-env test environment\n        run: npm run wp-env:test:start\n\n      - name: Run integration tests\n        run: npm run test\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n.tags*\n.wordpress_release\n*.sublime-*\n*.code-workspace\nwprelease.yml\ncomposer.phar\nnode_modules\njs/node_modules\nclient/node_modules\nvendor/*\nvendor-*/*\ntest/config.yml\ndoc\n.htaccess\nMix.json\nmix-manifest.json\n*.log\n.vscode\ndist\njs/dist\nlib/modules/podlove_web_player/player_v2/\n.php_cs.cache\n.php-cs-fixer.cache\n.build/wp*\n/.vs\nconfig.local.js\n*.cache\n"
  },
  {
    "path": ".gitmodules",
    "content": ""
  },
  {
    "path": ".php-cs-fixer.dist.php",
    "content": "<?php\n\n$finder = PhpCsFixer\\Finder::create()\n    ->exclude('vendor')\n    ->in(__DIR__)\n;\n\n$config = new PhpCsFixer\\Config();\n\n$c = $config->setRules([\n    '@PSR2' => true,\n    '@PhpCsFixer' => true,\n    'yoda_style' => false,\n    'fully_qualified_strict_types' => false,\n    'array_syntax' => ['syntax' => 'short'],\n    'trailing_comma_in_multiline' => false,\n    'no_trailing_comma_in_singleline_array' => true,\n    'blank_line_before_statement' => ['statements' => ['break', 'continue', 'declare', 'default', 'return', 'throw', 'try']],\n    'visibility_required' => ['elements' => ['method', 'property']]\n])\n    ->setFinder($finder)\n;\n\nreturn $c;\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"printWidth\": 100,\n    \"jsxBracketSameLine\": true,\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"bracketSpacing\": true,\n    \"tabWidth\": 2,\n    \"useTabs\": false\n  }\n"
  },
  {
    "path": ".wp-env.json",
    "content": "{\n  \"plugins\": [\".\"],\n  \"testsEnvironment\": false,\n  \"config\": {\n    \"WP_DEBUG\": true,\n    \"WP_DEBUG_LOG\": true,\n    \"WP_DEBUG_DISPLAY\": false\n  }\n}\n"
  },
  {
    "path": ".wp-env.test.json",
    "content": "{\n  \"port\": 8889,\n  \"testsEnvironment\": false,\n  \"mappings\": {\n    \"wp-content/plugins/podlove-podcasting-plugin-for-wordpress\": \".\"\n  },\n  \"lifecycleScripts\": {\n    \"afterStart\": \"node bin/wp-env-test-after-start.js\",\n    \"afterReset\": \"node bin/wp-env-test-after-start.js\"\n  },\n  \"config\": {\n    \"WP_DEBUG\": true,\n    \"WP_DEBUG_LOG\": true,\n    \"WP_DEBUG_DISPLAY\": false\n  }\n}\n"
  },
  {
    "path": ".zed/settings.json",
    "content": "{\n  \"languages\": {\n    \"PHP\": {\n      \"language_servers\": [\n        \"phpactor\",\n        \"!intelephense\",\n        \"!phptools\",\n        \"...\"\n      ],\n      \"formatter\": [\n        {\n          \"language_server\": {\n            \"name\": \"phpactor\"\n          }\n        }\n      ],\n      \"format_on_save\": \"on\"\n    }\n  },\n  \"lsp\": {\n    \"phpactor\": {\n      \"initialization_options\": {\n        \"language_server_php_cs_fixer.enabled\": true,\n        \"language_server_php_cs_fixer.bin\": \"%project_root%/vendor-bin/php-cs-fixer/vendor/friendsofphp/php-cs-fixer/php-cs-fixer\",\n        \"language_server_php_cs_fixer.config\": \"%project_root%/.php-cs-fixer.dist.php\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- `podlove.php` and `plugin.php` are the plugin entry points.\n- PHP source lives in `includes/` and `lib/`; templates in `templates/`; view helpers in `views/`.\n- Client assets are split between legacy JS in `js/` and the newer app in `client/`.\n- Static assets are in `css/`, `images/`, and `fonts/`.\n- Tests live in `tests/phpunit/` with suites under `integration/` and `rest/`.\n- Build artifacts are staged in `dist/` (generated by `make build`).\n\n## Build, Test, and Development Commands\n- `make install`: installs PHP tooling and prefixed dependencies (uses PHP-Scoper).\n- `make format`: runs PHP-CS-Fixer to format PHP code.\n- `npm run wp-env:start`: starts the local WordPress development environment on port 8888.\n- `npm run wp-env:stop`: stops the development `wp-env` environment.\n- `npm run wp-env:test:start`: starts the dedicated test `wp-env` environment on port 8889.\n- `npm run wp-env:test:stop`: stops the dedicated test environment.\n- `npm run test`: runs PHPUnit inside the dedicated test config's `cli` container (start it first).\n- DB quick query (read-only): `npx wp-env run cli -- wp db query \"SELECT * FROM wp_options LIMIT 5;\"`\n- Legacy JS dev: `cd js && npm install && npm run serve`.\n- Client dev: `cd client && npm install && npm run dev` (set `WORDPRESS_URL=...` for isolated dev).\n- Tool runtime: this repo defines tool versions in `mise.toml` (`php = 8.4`, `node = 25`). Prefer running PHP and other pinned tools through `mise`, for example `mise exec -- php -l path/to/file.php`, instead of assuming `php` is available on `PATH`.\n\n## Coding Style & Naming Conventions\n- Indentation: 2 spaces (see `.editorconfig`), LF line endings, max line length 120.\n- PHP formatting is enforced with PHP-CS-Fixer (`.php-cs-fixer.dist.php`).\n- Before completing a task, format any touched PHP files with the repo formatter. `make format` runs PHP-CS-Fixer for the whole repo; prefer the equivalent targeted command for only the files you changed, for example `mise exec -- vendor-bin/php-cs-fixer/vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix path/to/file.php --config .php-cs-fixer.dist.php`.\n- Use descriptive, WordPress-appropriate names for hooks and filters; keep filenames lowercase with underscores where applicable.\n\n## Testing Guidelines\n- PHPUnit is configured in `phpunit.xml.dist` and bootstraps via `tests/phpunit/bootstrap.php`.\n- Integration and REST tests live under `tests/phpunit/integration/` and `tests/phpunit/rest/`.\n- Run tests with `npm run wp-env:test:start` followed by `npm run test`.\n\n## Commit & Pull Request Guidelines\n- Commit messages follow a lightweight conventional style (examples: `feat: ...`, `fix: ...`, `chore: ...`, `change: ...`).\n- Changelog entries belong in `readme.txt` under the `== Changelog ==` header.\n- PRs should include a clear description, linked issues if applicable, and notes on how changes were tested (commands and environment).\n\n## Configuration Tips\n- Local WordPress uses `wp-env`; see `README.md` for ports and default credentials.\n- For release builds, use `make build` to generate a clean `dist/` directory.\n- Read-only database queries are allowed without asking for permission.\n- If a required runtime is missing from `PATH`, use `mise exec -- ...` from the repository root so commands run with the versions declared in `mise.toml`.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM wordpress:6-php8.1-apache\n\nRUN apt-get update\nRUN apt-get install zip default-mysql-client -y\nRUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && chmod +x wp-cli.phar && mv wp-cli.phar /usr/local/bin/wp\n\nWORKDIR /var/www/html\n\nCOPY ./bin/docker-entry.sh /usr/local/bin/entry.sh\nCOPY ./bin/docker-setup.sh /usr/local/bin/setup.sh\nCOPY ./dist wp-content/plugins/podlove-podcasting-plugin-for-wordpress\n\nENTRYPOINT [\"entry.sh\"]\n"
  },
  {
    "path": "Makefile",
    "content": "PHP_CS_FIXER = vendor-bin/php-cs-fixer/vendor/friendsofphp/php-cs-fixer/php-cs-fixer\n\nrelease:\n\tbin/release.sh\n\nformat:\n\t$(PHP_CS_FIXER) fix . --config .php-cs-fixer.dist.php\n\nvalidateFormat:\n\t$(PHP_CS_FIXER) fix . --config .php-cs-fixer.dist.php -v --dry-run --stop-on-violation --using-cache=no\n\nupdate_subscribe_button:\n\trm -rf .tmppsb\n\tgit clone https://github.com/podlove/podlove-subscribe-button.git .tmppsb\n\trm -rf lib/modules/subscribe_button/dist\n\tmv .tmppsb/dist lib/modules/subscribe_button/dist\n\trm -rf .tmppsb\n\nplayer:\n\tmkdir -p $(player_dst)/bin\n\tmkdir -p $(player_dst)/css\n\tmkdir -p $(player_dst)/img\n\tmkdir -p $(player_dst)/js/vendor\n\tcp -r $(player_src)/css/vendor $(player_dst)/css/vendor\n\tcp -r $(player_src)/img/* $(player_dst)/img\n\tcp -r $(player_src)/js/*.min.js $(player_dst)/js\n\tcp -r $(player_src)/js/vendor/*.min.js $(player_dst)/js/vendor\n\ncomposer_with_prefixing:\n\tmkdir -p vendor-prefixed\n\tcomposer install --no-progress --prefer-dist --optimize-autoloader --no-dev\n\tcomposer prefix-dependencies\n\trm -rf vendor/matomo\n\trm -rf vendor/twig\n\trm -rf vendor/monolog\n\trm -rf vendor/psr\n\tcomposer dump-autoload --classmap-authoritative\n\t# composer install --no-progress --prefer-dist --optimize-autoloader --no-dev\n\ninstall_php_scoper:\n\tmkdir -p vendor-prefixed\n\tcomposer require --dev bamarni/composer-bin-plugin:1.4.1\n\tcomposer bin php-scoper config minimum-stability dev\n\tcomposer bin php-scoper config prefer-stable true\n\tcomposer bin php-scoper require --dev --update-with-all-dependencies humbug/php-scoper:0.17.5\n\ninstall_php_cs_fixer:\n\tcomposer bin php-cs-fixer install\n\nclient_legacy:\n\tcd js && npm install\n\tcd js && NODE_ENV=production npm run build\n\nclient_next:\n\tcd client && npm install\n\tcd client && NODE_ENV=production npm run build\n\nclient: client_legacy client_next\n\nbuild:\n\tmake composer_with_prefixing\n\tmake client\n\n\trm -rf dist/*\n\tmkdir -p dist\n\t# move everything into dist\n\trsync -r --exclude=.git --exclude=node_modules --exclude=./dist . dist\n\t# cleanup\n\tfind dist -name \"*.git*\" | xargs rm -rf\n\trm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/libs\n\trm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/img/banner-772x250.png\n\trm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/img/banner-1544x500.png\n\trm -rf dist/client/src\n\trm -rf dist/client/package-lock.json\n\trm -rf dist/tests\n\trm -rf dist/vendor-bin\n\trm -rf dist/vendor/bin\n\trm -rf dist/vendor/phpunit/php-code-coverage\n\trm -rf dist/vendor/phpunit/phpunit\n\trm -rf dist/vendor/phpunit/phpunit-mock-objects\n\trm -rf dist/vendor/twig/twig/test\n\trm -rf dist/vendor/guzzle/guzzle/tests\n\trm -f dist/.travis.yml\n\trm -rf dist/bin\n\trm -f dist/wprelease.yml\n\trm -f dist/CONTRIBUTING.md\n\trm -f dist/Makefile\n\trm -f dist/phpunit.xml\n\trm -f dist/Rakefile\n\trm -f dist/README.md\n\trm -f dist/*.code-workspace\n\trm -f dist/.prettierrc\n\trm -f dist/.editorconfig\n\trm -rf dist/devbox.d\n\trm -f dist/devbox.json\n\trm -f dist/devbox.lock\n\trm -f dist/Dockerfile\n\trm -f dist/docker-compose.yml\n\tfind dist -name \"*composer.json\" | xargs rm -rf\n\tfind dist -name \"*composer.lock\" | xargs rm -rf\n\tfind dist -name \"*.swp\" | xargs rm -rf\n\t# find dist/vendor -type d -iname \"test\" | xargs rm -rf\n\t# find dist/vendor -type d -iname \"tests\" | xargs rm -rf\n\t# player v2 / mediaelement\n\tfind dist -iname \"echo-hereweare.*\" | xargs rm -rf\n\tfind dist -iname \"*.jar\" | xargs rm -rf\n\n\ninstall: install_php_scoper install_php_cs_fixer composer_with_prefixing\n"
  },
  {
    "path": "README.md",
    "content": "# Podlove Podcast Publisher\n\nThis is the podcast publishing plugin for WordPress.\n\n- [Getting Started & Documentation][6]\n- [Podlove Community][9]\n- Latest stable version: in [WordPress plugin directory][3]\n- [Podlove Project & Blog][7]\n- Report a bug: [Use GitHub Issues][5]\n\n[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fpodlove%2Fpodlove-publisher.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fpodlove%2Fpodlove-publisher?ref=badge_shield)\n\n## Development Setup\n\nPHP dependencies are managed via [Composer](http://getcomposer.org/). So you need to clone the repository and then fetch the dependencies via Composer. JavaScript packages are managed with [yarn](https://yarnpkg.com/lang/en/).\n\nClone the publisher in the `wp-content/plugins` directory.\n\n```\ngit clone --recursive https://github.com/podlove/podlove-publisher.git\ncd podlove-publisher\ncurl -sS https://getcomposer.org/installer | php\nmake install\n```\n\nIf you have a docker environment handy you can simply run:\n\n```\nmake docker-install\n```\n\n## Development\n\n### Legacy JS Development\n\n1. Change your working direcetory to `js/`\n2. Run `npm install`\n3. Run `npm run serve` to start the development build\n4. Go to your local Wordpress environment and see your changes\n\n### Client Development\n\n1. Create an [Wordpress application password](https://www.paidmembershipspro.com/create-application-password-wordpress/)\n2. Update the authorization tokens in= `client/index.html`\n3. Change your working directory to `client/`\n4. Run `npm install`\n5. For isolated development run `WORDPRESS_URL=http://podlove.local npm run dev` with your Wordpress environment\n6. For integrated development run `npm run serve` and go to your local Wordpress environment and see your changes\n\n## Testing\n\nIntegration tests use the official WordPress PHPUnit setup via `wp-env` with a dedicated isolated config.\n\nPrerequisites:\n- Docker is running\n- Node.js + npm\n- PHP + Composer\n\nFirst-time setup:\n```\ncomposer install\ncomposer bin php-scoper install\ncomposer bin php-cs-fixer install\ncomposer prefix-dependencies\nnpm install\nnpm run wp-env:test:start\n```\n\n## Local Development with wp-env\n\nYou can use `wp-env` to run a local WordPress instance with the plugin mounted from this repo.\n\nStart the environment:\n```\nnpm install\nnpm run wp-env:start\n```\n\nOpen:\n- Site: http://localhost:8888\n- Admin: http://localhost:8888/wp-admin\n- Default credentials: admin / password\n\nUseful commands:\n```\nnpm run wp-env:stop\nnpx wp-env destroy\nnpx wp-env run cli -- wp option get siteurl\n```\n\nRun integration tests:\n```\nnpm run wp-env:test:start\nnpm run test\n```\n\nStart the dedicated test environment:\n```\nnpm run wp-env:test:start\n```\n\nOpen:\n- Test site: http://localhost:8889\n\nUseful test commands:\n```\nnpm run wp-env:test:stop\nnpm run wp-env:test:destroy\nnpm run wp-env:test:logs\n```\n\nIf `wp-env run` fails with a missing docker-compose file, the environment was not created yet or was cleaned up. Recreate the relevant environment with:\n```\nnpx wp-env destroy\nnpm run wp-env:start\n```\n\nFor the test environment use:\n```\nnpm run wp-env:test:destroy\nnpm run wp-env:test:start\n```\n\n## Formatting Code\n\nUse [PHP-CS-Fixer](https://github.com/FriendsOfPhp/PHP-CS-Fixer) to format code before committing.\n\nYou can do so manually via command line (`make format`) or configure your editor to format the file on save. For VS Code, use the \"php cs fixer\" extension by junstyle.\n\n## Releases\n\nBoth beta and stable releases are creates with GitHub Actions.\n\nTo release a new stable version:\n\n1. _manually_ update the following fields in `readme.txt`:\n  - Tested up to\n  - Stable tag\n  - check that changelog has an entry\n2. `bash bin/release.sh`, which does:\n  - updates version in `podlove.php`\n  - creates release commit\n  - tags commit\n3. git push\n\nThe GitHub action detects the release via the tag, builds it and submits it to the wordpress.org plugin directory.\n\n[3]: https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/\n[4]: https://trello.com/b/zB4mKQlD/podlove-publisher\n[5]: https://github.com/podlove/podlove-publisher/issues\n[6]: http://docs.podlove.org/\n[7]: http://podlove.org/\n[8]: https://github.com/podlove/podlove-publisher/releases\n[9]: https://community.podlove.org/\n\n\n## License\n[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fpodlove%2Fpodlove-publisher.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fpodlove%2Fpodlove-publisher?ref=badge_large)\n"
  },
  {
    "path": "bin/code-coverage.sh",
    "content": "phpunit --coverage-html=../../coverage/report\necho \"Code Coverage Report: http://podlove-publisher.dev/wp-content/coverage/report/\"\n"
  },
  {
    "path": "bin/docker-entry.sh",
    "content": "#!/usr/bin/env bash\nset -Eeuo pipefail\n\n# Waiting for the MySQL server to start\nHOST=$(echo $WORDPRESS_DB_HOST | cut -d: -f1)\nPORT=$(echo $WORDPRESS_DB_HOST | cut -d: -f2)\n\nuntil mysql -h $HOST -P $PORT -D $WORDPRESS_DB_NAME -u $WORDPRESS_DB_USER -p$WORDPRESS_DB_PASSWORD -e '\\q'; do\n  >&2 echo \"Mysql is unavailable - sleeping...\"\n  sleep 2\ndone\n\nuid=\"$(id -u)\"\ngid=\"$(id -g)\"\n\nif [ \"$uid\" = '0' ]; then\n  user=\"${APACHE_RUN_USER:-www-data}\"\n  group=\"${APACHE_RUN_GROUP:-www-data}\"\n\n  # strip off any '#' symbol ('#1000' is valid syntax for Apache)\n  pound='#'\n  user=\"${user#$pound}\"\n  group=\"${group#$pound}\"\n\n\tif [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then\n\t\t# if the directory exists and WordPress doesn't appear to be installed AND the permissions of it are root:root, let's chown it (likely a Docker-created directory)\n\t\tif [ \"$uid\" = '0' ] && [ \"$(stat -c '%u:%g' .)\" = '0:0' ]; then\n\t\t\tchown \"$user:$group\" .\n\t\tfi\n\n\t\techo >&2 \"WordPress not found in $PWD - copying now...\"\n\t\tif [ -n \"$(find -mindepth 1 -maxdepth 1 -not -name wp-content)\" ]; then\n\t\t\techo >&2 \"WARNING: $PWD is not empty! (copying anyhow)\"\n\t\tfi\n\t\tsourceTarArgs=(\n\t\t\t--create\n\t\t\t--file -\n\t\t\t--directory /usr/src/wordpress\n\t\t\t--owner \"$user\" --group \"$group\"\n\t\t)\n\t\ttargetTarArgs=(\n\t\t\t--extract\n\t\t\t--file -\n\t\t)\n\t\tif [ \"$uid\" != '0' ]; then\n\t\t\t# avoid \"tar: .: Cannot utime: Operation not permitted\" and \"tar: .: Cannot change mode to rwxr-xr-x: Operation not permitted\"\n\t\t\ttargetTarArgs+=( --no-overwrite-dir )\n\t\tfi\n\t\t# loop over \"pluggable\" content in the source, and if it already exists in the destination, skip it\n\t\t# https://github.com/docker-library/wordpress/issues/506 (\"wp-content\" persisted, \"akismet\" updated, WordPress container restarted/recreated, \"akismet\" downgraded)\n\t\tfor contentPath in \\\n\t\t\t/usr/src/wordpress/.htaccess \\\n\t\t\t/usr/src/wordpress/wp-content/*/*/ \\\n\t\t; do\n\t\t\tcontentPath=\"${contentPath%/}\"\n\t\t\t[ -e \"$contentPath\" ] || continue\n\t\t\tcontentPath=\"${contentPath#/usr/src/wordpress/}\" # \"wp-content/plugins/akismet\", etc.\n\t\t\tif [ -e \"$PWD/$contentPath\" ]; then\n\t\t\t\techo >&2 \"WARNING: '$PWD/$contentPath' exists! (not copying the WordPress version)\"\n\t\t\t\tsourceTarArgs+=( --exclude \"./$contentPath\" )\n\t\t\tfi\n\t\tdone\n\t\ttar \"${sourceTarArgs[@]}\" . | tar \"${targetTarArgs[@]}\"\n\t\techo >&2 \"Complete! WordPress has been successfully copied to $PWD\"\n\tfi\n\n\twpEnvs=( \"${!WORDPRESS_@}\" )\n\tif [ ! -s wp-config.php ] && [ \"${#wpEnvs[@]}\" -gt 0 ]; then\n\t\tfor wpConfigDocker in \\\n\t\t\twp-config-docker.php \\\n\t\t\t/usr/src/wordpress/wp-config-docker.php \\\n\t\t; do\n\t\t\tif [ -s \"$wpConfigDocker\" ]; then\n\t\t\t\techo >&2 \"No 'wp-config.php' found in $PWD, but 'WORDPRESS_...' variables supplied; copying '$wpConfigDocker' (${wpEnvs[*]})\"\n\t\t\t\t# using \"awk\" to replace all instances of \"put your unique phrase here\" with a properly unique string (for AUTH_KEY and friends to have safe defaults if they aren't specified with environment variables)\n\t\t\t\tawk '\n\t\t\t\t\t/put your unique phrase here/ {\n\t\t\t\t\t\tcmd = \"head -c1m /dev/urandom | sha1sum | cut -d\\\\  -f1\"\n\t\t\t\t\t\tcmd | getline str\n\t\t\t\t\t\tclose(cmd)\n\t\t\t\t\t\tgsub(\"put your unique phrase here\", str)\n\t\t\t\t\t}\n\t\t\t\t\t{ print }\n\t\t\t\t' \"$wpConfigDocker\" > wp-config.php\n\t\t\t\tif [ \"$uid\" = '0' ]; then\n\t\t\t\t\t# attempt to ensure that wp-config.php is owned by the run user\n\t\t\t\t\t# could be on a filesystem that doesn't allow chown (like some NFS setups)\n\t\t\t\t\tchown \"$user:$group\" wp-config.php || true\n\t\t\t\tfi\n\t\t\t\tbreak\n\t\t\tfi\n\t\tdone\n\tfi\nfi\n\neval setup.sh\napache2-foreground\n"
  },
  {
    "path": "bin/docker-setup.sh",
    "content": "#!/usr/bin/env bash\n"
  },
  {
    "path": "bin/release.sh",
    "content": "#!/usr/bin/env bash\n\nPLUGIN_FILE=./podlove.php\n\nCURRENT_VERSION=`head -n 20 $PLUGIN_FILE | grep \"Version:\" | cut -d: -f2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'`\n\necho \"Creating a release will:\"\necho \"  - update the version in $PLUGIN_FILE\"\necho \"  - commit that change\"\necho \"  - create a git tag on that commit with the given version\"\necho \"\"\n\necho Current Version: $CURRENT_VERSION\necho \"Input new version:\"\nread version\n\necho \"----------\"\necho \"Current Version: $CURRENT_VERSION\"\necho \"New Version:     $version\"\necho \"----------\"\n\nwhile true; do\n    read -p \"Correct & Continue?\" yn\n    case $yn in\n        [Yy]* )\n          sed -i.bak \"s/\\(Version:\\).*/\\1 `echo $version | rev | cut -d/ -f1 | rev`/\" $PLUGIN_FILE\n          rm $PLUGIN_FILE.bak\n          git add $PLUGIN_FILE\n          git commit -m \"release $version\"\n          git tag -f $version -m $version\n          break;;\n        [Nn]* ) exit;;\n        * ) echo \"Please answer yes or no.\";;\n    esac\ndone\n"
  },
  {
    "path": "bin/remove-tunnel.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Reset WordPress URLs back to local defaults after using a tunnel.\n# Run this from within a Local site shell (where wp-config.php is present)\n# or from the plugin root when using wp-env.\n#\n# Usage:\n#   bin/remove-tunnel.sh\n#\n# Override defaults with:\n#   LOCAL_HOST=publisher.local LOCAL_PORT=80 LOCAL_SCHEME=https bin/remove-tunnel.sh\n\nif ! command -v wp >/dev/null 2>&1; then\n  echo \"Error: wp (WP-CLI) is not available in PATH.\" >&2\n  exit 1\nfi\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\nLOCAL_WP_ENV_BIN=\"${REPO_ROOT}/node_modules/.bin/wp-env\"\n\n# Local defaults (Local app). wp-env defaults to localhost:8888.\nLOCAL_HOST=\"${LOCAL_HOST:-localhost}\"\nLOCAL_SCHEME=\"${LOCAL_SCHEME:-http}\"\nLOCAL_PORT=\"${LOCAL_PORT:-8888}\"\nLOCAL_URL=\"${LOCAL_SCHEME}://${LOCAL_HOST}:${LOCAL_PORT}/\"\nWP_URL=\"${WP_URL:-$LOCAL_URL}\"\n\nset_wp_cli() {\n  if [ -f \"wp-config.php\" ]; then\n    WP=\"wp\"\n    return\n  fi\n\n  if command -v wp-env >/dev/null 2>&1; then\n    WP=\"wp-env run cli wp\"\n    return\n  fi\n\n  if [ -x \"${LOCAL_WP_ENV_BIN}\" ]; then\n    WP=\"${LOCAL_WP_ENV_BIN} run cli wp\"\n    return\n  fi\n\n  if command -v npx >/dev/null 2>&1; then\n    if npx --no-install wp-env --version >/dev/null 2>&1; then\n      WP=\"npx --no-install wp-env run cli wp\"\n      return\n    fi\n  fi\n\n  echo \"Error: wp-config.php not found and wp-env is not available.\" >&2\n  echo \"Run this from a WordPress root or from the plugin root with wp-env installed.\" >&2\n  echo \"Tip: run npm install (or npm run wp-env:start) to install wp-env locally.\" >&2\n  exit 1\n}\n\nset_wp_cli\n\ncat <<INFO\nResetting WordPress URLs to local defaults:\n  Local URL: ${LOCAL_URL}\n  URL:       ${WP_URL}\nINFO\n\n${WP} option update home \"${WP_URL}\" >/dev/null 2>&1 || true\n${WP} option update siteurl \"${WP_URL}\" >/dev/null 2>&1 || true\n${WP} rewrite flush --hard >/dev/null 2>&1 || true\n\n# Local/wp-env may inject redirects via wp-config constants. Clear then set.\n${WP} config delete WP_HOME --type=constant >/dev/null 2>&1 || true\n${WP} config delete WP_SITEURL --type=constant >/dev/null 2>&1 || true\n${WP} config set WP_HOME \"${WP_URL}\" --type=constant >/dev/null 2>&1 || true\n${WP} config set WP_SITEURL \"${WP_URL}\" --type=constant >/dev/null 2>&1 || true\n\necho \"Done. WordPress URL reset to local defaults.\"\n"
  },
  {
    "path": "bin/reset-nux.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Full WordPress reset + fresh install for new-user experience testing.\n# Run this from within a Local site shell (where wp-config.php is present)\n# or from the plugin root when using wp-env.\n#\n# Usage:\n#   bin/reset-nux.sh               # reset using LOCAL_URL\n#   bin/reset-nux.sh --tunnel      # reset and set site URLs to ngrok public URL\n#\n# wp-env defaults:\n# - Run from plugin root; wp-env exposes WP at http://localhost:8888 by default.\n# - Override host/port (e.g. pretty URLs) using wp-env + Caddy:\n#   1) Add a .wp-env.override.json like:\n#      {\n#        \"port\": 80,\n#        \"host\": \"publisher.local\"\n#      }\n#   2) Add to /etc/hosts:\n#      127.0.0.1 publisher.local\n#   3) Run Caddy to serve HTTPS locally:\n#      caddy reverse-proxy --from https://publisher.local --to http://localhost:80\n#   4) Then run:\n#      LOCAL_HOST=publisher.local LOCAL_PORT=80 LOCAL_SCHEME=https bin/reset-nux.sh\n\nif ! command -v wp >/dev/null 2>&1; then\n  echo \"Error: wp (WP-CLI) is not available in PATH.\" >&2\n  exit 1\nfi\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\nLOCAL_WP_ENV_BIN=\"${REPO_ROOT}/node_modules/.bin/wp-env\"\n\nTUNNEL=false\nwhile [ $# -gt 0 ]; do\n  case \"$1\" in\n    --tunnel)\n      TUNNEL=true\n      ;;\n    *)\n      echo \"Error: unknown argument: $1\" >&2\n      echo \"Usage: $0 [--tunnel]\" >&2\n      exit 1\n      ;;\n  esac\n  shift\ndone\n\n# Local defaults (Local app). wp-env defaults to localhost:8888.\nLOCAL_HOST=\"${LOCAL_HOST:-localhost}\"\nLOCAL_SCHEME=\"${LOCAL_SCHEME:-http}\"\nLOCAL_PORT=\"${LOCAL_PORT:-8888}\"\nLOCAL_URL=\"${LOCAL_SCHEME}://${LOCAL_HOST}:${LOCAL_PORT}/\"\nWP_URL=\"${WP_URL:-$LOCAL_URL}\"\nWP_TITLE=\"${WP_TITLE:-NUX Test}\"\nWP_ADMIN_USER=\"${WP_ADMIN_USER:-admin}\"\nWP_ADMIN_PASS=\"${WP_ADMIN_PASS:-admin}\"\nWP_ADMIN_EMAIL=\"${WP_ADMIN_EMAIL:-admin@example.com}\"\nPLUGIN_SLUG=\"${PLUGIN_SLUG:-podlove-podcasting-plugin-for-wordpress}\"\nNGROK_PID=\"\"\nNGROK_LOG=\"\"\n\ncleanup() {\n  if [ \"${TUNNEL}\" = true ]; then\n    return\n  fi\n  if [ -n \"${NGROK_PID}\" ] && kill -0 \"${NGROK_PID}\" >/dev/null 2>&1; then\n    kill \"${NGROK_PID}\" >/dev/null 2>&1 || true\n  fi\n}\n\ntrap cleanup EXIT\n\nprint_ngrok_session_limit_hint() {\n  if ! rg -q \"ERR_NGROK_108|simultaneous ngrok agent sessions|limited to 1 simultaneous\" \"${NGROK_LOG}\" 2>/dev/null; then\n    return\n  fi\n\n  echo >&2\n  echo \"ngrok reports another agent session is already running.\" >&2\n  echo \"Find running ngrok processes:\" >&2\n  echo \"  pgrep -fl ngrok\" >&2\n  echo \"Stop them:\" >&2\n  echo \"  pkill -f ngrok\" >&2\n}\n\nprint_ngrok_authtoken_hint() {\n  if ! rg -q \"ERR_NGROK_4018|authtoken|authentication failed\" \"${NGROK_LOG}\" 2>/dev/null; then\n    return\n  fi\n\n  echo >&2\n  echo \"ngrok needs an authtoken. Fix with:\" >&2\n  echo \"  ngrok config add-authtoken <YOUR_TOKEN>\" >&2\n}\n\nstart_ngrok_tunnel() {\n  if ! command -v ngrok >/dev/null 2>&1; then\n    echo \"Error: ngrok is required for --tunnel but was not found in PATH.\" >&2\n    exit 1\n  fi\n\n  if ! curl -fsS --max-time 2 \"http://127.0.0.1:${LOCAL_PORT}/\" >/dev/null 2>&1 \\\n    && ! curl -fsS --max-time 2 \"http://localhost:${LOCAL_PORT}/\" >/dev/null 2>&1; then\n    echo \"Error: local WordPress is not reachable on http://localhost:${LOCAL_PORT}/.\" >&2\n    echo \"Check Docker port mappings (wp-env should map ${LOCAL_PORT}->80):\" >&2\n    echo \"  docker ps --format '{{.Names}}\\\\t{{.Ports}}' | rg 'wordpress'\" >&2\n    echo \"If the port differs, set LOCAL_PORT or LOCAL_HOST before running this script.\" >&2\n    exit 1\n  fi\n\n  # Tunnel over plain HTTP to Local's router port; Local handles HTTPS itself.\n  local tunnel_target=\"http://${LOCAL_HOST}:${LOCAL_PORT}/\"\n  echo \"Starting ngrok tunnel for: ${tunnel_target}\"\n\n  # Start ngrok in the background and resolve the public URL via the local API.\n  # macOS mktemp requires a template with at least 3-6 X's.\n  NGROK_LOG=\"$(mktemp -t ngrok-reset-nux.XXXXXX)\"\n  local host_header=\"--host-header=${LOCAL_HOST}:${LOCAL_PORT}\"\n  case \"${WP}\" in\n    *wp-env*)\n      host_header=\"--host-header=preserve\"\n      ;;\n  esac\n  ngrok http \"${tunnel_target}\" ${host_header} --log=stdout >\"${NGROK_LOG}\" 2>&1 &\n  NGROK_PID=$!\n  echo \"ngrok PID: ${NGROK_PID}\"\n  echo \"ngrok log: ${NGROK_LOG}\"\n\n  sleep 1\n\n  if ! kill -0 \"${NGROK_PID}\" >/dev/null 2>&1; then\n    echo \"Error: ngrok exited immediately.\" >&2\n    echo \"ngrok output:\" >&2\n    sed -n '1,80p' \"${NGROK_LOG}\" >&2 || true\n    print_ngrok_session_limit_hint\n    print_ngrok_authtoken_hint\n    exit 1\n  fi\n\n  local tunnel_url=\"\"\n  local attempts=0\n  local max_attempts=20\n\n  while [ ${attempts} -lt ${max_attempts} ]; do\n    attempts=$((attempts + 1))\n\n    if ! kill -0 \"${NGROK_PID}\" >/dev/null 2>&1; then\n      echo \"Error: ngrok stopped while waiting for the tunnel URL.\" >&2\n      echo \"ngrok output:\" >&2\n      sed -n '1,120p' \"${NGROK_LOG}\" >&2 || true\n      exit 1\n    fi\n\n    tunnel_url=\"$(\n      curl -fsS http://127.0.0.1:4040/api/tunnels 2>/dev/null \\\n        | php -n -r '$d=json_decode(stream_get_contents(STDIN), true); if (!is_array($d)) { exit(1); } foreach (($d[\"tunnels\"] ?? []) as $t) { $u=$t[\"public_url\"] ?? \"\"; if (strpos($u, \"https://\") === 0) { echo $u; exit(0); } } exit(1);' \\\n        || true\n    )\"\n\n    if [ -n \"${tunnel_url}\" ]; then\n      echo \"ngrok public URL: ${tunnel_url}\"\n      WP_URL=\"${tunnel_url}\"\n      return\n    fi\n\n    sleep 1\n  done\n\n  echo \"Error: ngrok tunnel did not become ready via http://127.0.0.1:4040/api/tunnels.\" >&2\n  echo \"ngrok output (first lines):\" >&2\n  sed -n '1,120p' \"${NGROK_LOG}\" >&2 || true\n  print_ngrok_session_limit_hint\n  print_ngrok_authtoken_hint\n  echo \"Tip: make sure no other ngrok process is running and try again.\" >&2\n  exit 1\n}\n\nset_wp_cli() {\n  if [ -f \"wp-config.php\" ]; then\n    WP=\"wp\"\n    return\n  fi\n\n  if command -v wp-env >/dev/null 2>&1; then\n    WP=\"wp-env run cli wp\"\n    return\n  fi\n\n  if [ -x \"${LOCAL_WP_ENV_BIN}\" ]; then\n    WP=\"${LOCAL_WP_ENV_BIN} run cli wp\"\n    return\n  fi\n\n  if command -v npx >/dev/null 2>&1; then\n    if npx --no-install wp-env --version >/dev/null 2>&1; then\n      WP=\"npx --no-install wp-env run cli wp\"\n      return\n    fi\n  fi\n\n  echo \"Error: wp-config.php not found and wp-env is not available.\" >&2\n  echo \"Run this from a WordPress root or from the plugin root with wp-env installed.\" >&2\n  echo \"Tip: run npm install (or npm run wp-env:start) to install wp-env locally.\" >&2\n  exit 1\n}\n\nset_wp_cli\n\nif [ \"${TUNNEL}\" = true ]; then\n  start_ngrok_tunnel\nfi\n\ncat <<INFO\nResetting WordPress with:\n  Local URL:    ${LOCAL_URL}\n  URL:          ${WP_URL}\n  Tunnel:       ${TUNNEL}\n  Title:        ${WP_TITLE}\n  Admin user:   ${WP_ADMIN_USER}\n  Admin email:  ${WP_ADMIN_EMAIL}\n  Plugin slug:  ${PLUGIN_SLUG}\nINFO\n\n${WP} db reset --yes\n\n${WP} core install \\\n  --url=\"${WP_URL}\" \\\n  --title=\"${WP_TITLE}\" \\\n  --admin_user=\"${WP_ADMIN_USER}\" \\\n  --admin_password=\"${WP_ADMIN_PASS}\" \\\n  --admin_email=\"${WP_ADMIN_EMAIL}\"\n\n# Persist the URL explicitly for REST callbacks (important for tunnels).\n${WP} option update home \"${WP_URL}\" >/dev/null 2>&1 || true\n${WP} option update siteurl \"${WP_URL}\" >/dev/null 2>&1 || true\n${WP} rewrite flush --hard >/dev/null 2>&1 || true\n\n# Local/wp-env may inject redirects via wp-config constants. Clear then set.\n${WP} config delete WP_HOME --type=constant >/dev/null 2>&1 || true\n${WP} config delete WP_SITEURL --type=constant >/dev/null 2>&1 || true\n${WP} config set WP_HOME \"${WP_URL}\" --type=constant >/dev/null 2>&1 || true\n${WP} config set WP_SITEURL \"${WP_URL}\" --type=constant >/dev/null 2>&1 || true\n\nactivate_podlove_plugin() {\n  local candidates=(\n    \"${PLUGIN_SLUG}\"\n    \"podlove-podcast-publisher\"\n    \"podlove-publisher\"\n    \"$(basename \"${REPO_ROOT}\")\"\n  )\n  local seen=\"\"\n  local slug=\"\"\n\n  for slug in \"${candidates[@]}\"; do\n    case \" ${seen} \" in\n      *\" ${slug} \"*) continue ;;\n    esac\n    seen=\"${seen} ${slug}\"\n    if ${WP} plugin is-installed \"${slug}\" >/dev/null 2>&1; then\n      ${WP} plugin activate \"${slug}\" || true\n      return\n    fi\n  done\n\n  echo \"Warning: Podlove plugin not found. Tried: ${seen# }\" >&2\n}\n\n# Ensure the plugin repo is active after reinstall.\nactivate_podlove_plugin\n\n# For convenience, also activate web player\n${WP} plugin activate podlove-web-player || true\n\nif [ \"${TUNNEL}\" = true ]; then\n  echo \"Done. Fresh install configured for tunnel URL: ${WP_URL}\"\n  echo \"ngrok is still running in the background (PID: ${NGROK_PID}).\"\n  echo \"Stop it with: kill ${NGROK_PID}\"\n  echo \"Switch back to local URLs with: bin/remove-tunnel.sh\"\nelse\n  echo \"Done. Fresh WordPress install and plugin activation attempted.\"\nfi\n"
  },
  {
    "path": "bin/template_ref.erb",
    "content": "<% templateRefClasses.each do |item| %>\n<a id=\"podlove-class-<%= item['class']['templatetag'] %>\"></a>\n\n#### <%= item['class']['classname'] %>\n\n<%= renderDescription(item['class']['description']) %>\n\n<table>\n    <% item['methods'].each do |method| %>\n        <tr>\n            <td valign=\"top\">\n                <code>\n                    <%= item['class']['templatetag'] %>.<%= method['methodname'] %>\n                </code>\n            </td>\n            <td>\n                <strong>\n                    <%= method['title'] %>\n                </strong>\n                <%= renderDescription(method['description']) %>\n                <%\n                seeTags = method['tags'].keep_if { |tag| tag['name'] == 'see' }\n                if seeTags && seeTags.length > 0\n                    %><p><% \n                    seeTags.each do |tag| %>\n                        see <a href=\"#podlove-class-<%= tag['description'] %>\"><%= renderDescription(tag['description']) %></a>\n                    <% end\n                    %></p><% \n                end %>\n            </td>\n        </tr>\n    <% end %>\n</table> \n<% end %>"
  },
  {
    "path": "bin/template_ref.rb",
    "content": "require \"erb\"\nrequire \"json\"\n\ndef templateRefClasses\n\tclasses = %w{podcast episode network list chapter feed asset file image tag category duration file_type contributor contributor_group season service show license flattr datetime line group}\n\tclasses.map { |klass| JSON.parse(IO.read(\"doc/data/template/#{klass}.json\")) }\nend\n\ndef renderDescription(s)\n\tif s\n\t\ts.gsub!(/^(\\s+)```/, \"\\n```\")\n\t\ts.gsub!(/```(\\w+)?([^`]+)```/) { |m| \"```\" + $1.to_s + \"\\n{% raw %}\" + $2.to_s + \"{% endraw %}\\n```\" }\n\t\ts.gsub!(/[^`]`([^`]+)`/) { |m| \"`{% raw %}\" + $1.to_s + \"{% endraw %}`\" }\n\t\ts = \"{% capture tmp %}\" + s + \"{% endcapture %}\\n{{ tmp | markdownify }}\"\n\tend\n\n\ts\nend\n\nrenderer = ERB.new(File.read(\"bin/template_ref.erb\"))\nputs output = renderer.result()\n"
  },
  {
    "path": "bin/template_ref_json.php",
    "content": "<?php\n\n/**\n * Extracts template reference and saves them to JSON files.\n *\n * Complete workflow for generating reference markdown:\n *\n * - Warning: Does NOT work on multisite installs! Set `define('MULTISITE', false);`!\n * - use `php -d \"opcache.enable=off\"` to avoid opcache removing comments\n *\n * 1. $> WPBASE=/path/to/wordpress php -d \"opcache.enable=off\" bin/template_ref_json.php\n * 2. $> ruby bin/template_ref.rb > doc/template_ref.md\n */\nrequire_once 'vendor/autoload.php';\n\nuse Podlove\\Comment\\Comment;\n\ndefine('MULTISITE', false);\n\nif (!getenv('WPBASE')) {\n    exit(\"You need to set the environment variable WPBASE to your WordPress root\\n\");\n}\n\nrequire_once dirname(__FILE__).'/../lib/helper.php';\nrequire_once getenv('WPBASE').'/wp-load.php';\nrequire_once dirname(__FILE__).'/../bootstrap/bootstrap.php';\n\n// $output_dir = '/tmp/podlove/doc';\n$output_dir = dirname(__FILE__).'/../doc/data/template';\n@mkdir($output_dir, 0777, true);\n\n// classes containing dynamic accessors\n$dynamicAccessorClasses = [\n    '\\Podlove\\Modules\\Contributors\\TemplateExtensions',\n    '\\Podlove\\Modules\\Seasons\\TemplateExtensions',\n    '\\Podlove\\Modules\\RelatedEpisodes\\TemplateExtensions',\n    '\\Podlove\\Modules\\Shows\\TemplateExtensions',\n    '\\Podlove\\Modules\\Social\\TemplateExtensions',\n    '\\Podlove\\Modules\\SubscribeButton\\TemplateExtensions',\n    '\\Podlove\\Modules\\Transcripts\\TemplateExtensions',\n];\n\n$classes = [\n    '\\Podlove\\Template\\Podcast',\n    '\\Podlove\\Template\\Feed',\n    '\\Podlove\\Template\\Episode',\n    '\\Podlove\\Template\\EpisodeTitle',\n    '\\Podlove\\Template\\Asset',\n    '\\Podlove\\Template\\File',\n    '\\Podlove\\Template\\Duration',\n    '\\Podlove\\Template\\Chapter',\n    '\\Podlove\\Template\\License',\n    '\\Podlove\\Template\\DateTime',\n    '\\Podlove\\Template\\FileType',\n    '\\Podlove\\Template\\Tag',\n    '\\Podlove\\Template\\Category',\n    '\\Podlove\\Template\\Image',\n    '\\Podlove\\Modules\\Contributors\\Template\\Contributor',\n    '\\Podlove\\Modules\\Contributors\\Template\\ContributorGroup',\n    '\\Podlove\\Modules\\Seasons\\Template\\Season',\n    '\\Podlove\\Modules\\Shows\\Template\\Show',\n    '\\Podlove\\Modules\\Social\\Template\\Service',\n    '\\Podlove\\Modules\\Networks\\Template\\Network',\n    '\\Podlove\\Modules\\Networks\\Template\\PodcastList',\n    '\\Podlove\\Modules\\Transcripts\\Template\\Line',\n    '\\Podlove\\Modules\\Transcripts\\Template\\Group',\n];\n\n// first, parse dynamic accessors\n$dynamicAccessors = [];\nforeach ($dynamicAccessorClasses as $class) {\n    $reflectionClass = new ReflectionClass($class);\n    $methods = $reflectionClass->getMethods();\n\n    $accessors = array_filter($methods, function ($method) {\n        $comment = $method->getDocComment();\n\n        return stripos($comment, '@accessor') !== false && stripos($comment, '@dynamicAccessor') !== false;\n    });\n\n    $parsedMethods = array_map(function ($method) {\n        assert_options(ASSERT_CALLBACK, function () use ($method) {\n            print_r(\"!!! Assertion failed in {$method->class}::{$method->name}\\n\");\n        });\n\n        $c = new Comment($method->getDocComment());\n        $c->parse();\n\n        $dynamicAccessor = $c->getTag('dynamicAccessor');\n        $callData = explode('.', $dynamicAccessor['description']);\n\n        return [\n            'methodname' => $callData[1],\n            'title' => $c->getTitle(),\n            'description' => $c->getDescription(),\n            'tags' => $c->getTags(),\n            'class' => $callData[0],\n        ];\n    }, $accessors);\n\n    foreach ($parsedMethods as $method) {\n        if (!isset($dynamicAccessors[$method['class']])) {\n            $dynamicAccessors[$method['class']] = [];\n        }\n\n        $dynamicAccessors[$method['class']][$method['methodname']] = $method;\n    }\n}\n\nforeach ($classes as $class) {\n    $reflectionClass = new ReflectionClass($class);\n    $className = $reflectionClass->getShortName();\n    $methods = $reflectionClass->getMethods();\n\n    $accessors = array_filter($methods, function ($method) {\n        $comment = $method->getDocComment();\n\n        return stripos($comment, '@accessor') !== false;\n    });\n\n    $parsedMethods = array_map(function ($method) {\n        $c = new Comment($method->getDocComment());\n        $c->parse();\n\n        return [\n            'methodname' => $method->name,\n            'title' => $c->getTitle(),\n            'description' => $c->getDescription(),\n            'tags' => $c->getTags(),\n        ];\n    }, $accessors);\n\n    if (isset($dynamicAccessors[strtolower($className)])) {\n        foreach ($dynamicAccessors[strtolower($className)] as $dynamicMethodName => $dynamicMethod) {\n            $parsedMethods[] = $dynamicMethod;\n        }\n    }\n\n    $classComment = new Comment($reflectionClass->getDocComment());\n    $classComment->parse();\n    $templatetag = $classComment->getTags()[0]['description'];\n\n    assert(strlen($templatetag) > 0, 'templatetag must not be empty');\n\n    $classdoc = [\n        'class' => [\n            'classname' => $className,\n            'templatetag' => $templatetag,\n            'description' => $classComment->getDescription(),\n        ],\n        'methods' => array_values($parsedMethods),\n    ];\n\n    file_put_contents($output_dir.'/'.$templatetag.'.json', wp_json_encode($classdoc), LOCK_EX);\n}\n"
  },
  {
    "path": "bin/uadetect.php",
    "content": "<?php\n\nrequire_once 'vendor/autoload.php';\n\nuse PodlovePublisher_Vendor\\DeviceDetector\\DeviceDetector;\n\n$userAgent = $argv[1];\n$dd = new DeviceDetector($userAgent);\n$dd->parse();\n\nif ($dd->isBot()) {\n    var_dump($botInfo = $dd->getBot());\n} else {\n    $clientInfo = $dd->getClient(); // holds information about browser, feed reader, media player, ...\n    $osInfo = $dd->getOs();\n    $device = $dd->getDevice();\n    $brand = $dd->getBrand();\n    $model = $dd->getModel();\n    var_dump($clientInfo, $osInfo, $device, $brand, $model);\n}\n"
  },
  {
    "path": "bin/update-opawg.sh",
    "content": "#!/usr/bin/env bash\n\nwget -O data/opawg.json https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json\n"
  },
  {
    "path": "bin/update_pwp4.sh",
    "content": "#!/usr/bin/env bash\n\nnpm update @podlove/web-player\nrm -r lib/modules/podlove_web_player/player_v4/dist\ncp -r node_modules/@podlove/web-player/ lib/modules/podlove_web_player/player_v4/dist\n"
  },
  {
    "path": "bin/workspace.js",
    "content": "const path = require('path')\nconst fs = require('fs-extra')\n\nconst toDelete = (fs.readdirSync(path.resolve('.')) || []).filter(item => item !== 'dist')\n\ntoDelete.forEach(file => fs.removeSync(path.resolve(file)))\n\nconst toCopy = fs.readdirSync(path.resolve('dist')) || []\n\ntoCopy.forEach(file => fs.copySync(path.resolve('dist', file), path.resolve('.', file)))\n\nfs.removeSync(path.resolve('dist'))\n"
  },
  {
    "path": "bin/wp-env-test-after-start.js",
    "content": "const { spawnSync } = require('node:child_process');\nconst path = require('node:path');\n\nconst repoRoot = path.resolve(__dirname, '..');\nconst wpEnvBin = path.join(repoRoot, 'node_modules', '.bin', 'wp-env');\nconst configArg = '--config=.wp-env.test.json';\nconst pluginSlug = 'podlove-podcasting-plugin-for-wordpress';\n\nfunction runWpEnv(args) {\n  return spawnSync(wpEnvBin, [configArg, 'run', 'cli', 'wp', ...args], {\n    cwd: repoRoot,\n    stdio: 'inherit',\n  });\n}\n\nconst isActive = runWpEnv(['plugin', 'is-active', pluginSlug]);\n\nif (isActive.status === 0) {\n  process.exit(0);\n}\n\nconst activated = runWpEnv(['plugin', 'activate', pluginSlug]);\nprocess.exit(activated.status ?? 1);\n"
  },
  {
    "path": "bootstrap/autoload.php",
    "content": "<?php\n\nfunction podlove_camelcase_to_snakecase($string)\n{\n    return preg_replace('/([a-z])([A-Z])/', '$1_$2', $string);\n}\n\nfunction podlove_camelsnakecase_to_camelcase($string)\n{\n    return str_replace('_', '', $string);\n}\n\nfunction podlove_snakecase_to_camelsnakecase($string)\n{\n    return ucwords(preg_replace_callback('/_\\w/', function ($m) {\n        return strtoupper($m[0]);\n    }, $string));\n}\n\n// autoload all classes in /lib\nfunction podlove_autoloader($class_name)\n{\n    // get class name without namespace\n    $split = explode('\\\\', $class_name);\n    // remove <Plugin> namespace\n    $plugin = array_shift($split);\n\n    if (!strlen($plugin)) {\n        $plugin = array_shift($split);\n    }\n\n    // only load classes prefixed with <Plugin> namespace\n    if ($plugin != 'Podlove') {\n        return false;\n    }\n\n    // class name without namespace\n    $class_name = array_pop($split);\n    // CamelCase to snake_case\n    $class_name = podlove_camelcase_to_snakecase($class_name);\n\n    // the rest of the namespace, if any\n    $namespaces = $split;\n\n    // library directory\n    $lib = dirname(dirname(__FILE__)).'/lib/';\n\n    // register all possible paths for the class\n    $possibilities = [];\n    if (count($namespaces) >= 1) {\n        $possibilities[] = $lib.strtolower(implode('/', array_map('podlove_camelcase_to_snakecase', $namespaces)).'/'.$class_name.'.php');\n    } else {\n        $possibilities[] = $lib.strtolower($class_name.'.php');\n    }\n\n    // search for the class\n    foreach ($possibilities as $file) {\n        if (file_exists($file)) {\n            require_once $file;\n\n            return true;\n        }\n    }\n\n    if (defined('WP_DEBUG') && WP_DEBUG) {\n        $trace = debug_backtrace();\n        $functions = array_map(function ($t) {\n            return $t['function'];\n        }, $trace);\n\n        if (in_array('class_exists', $functions)) {\n            // don't log anything, we were just checking if that class exists\n        } else {\n            error_log(print_r([\n                'message' => 'Class Autoload failed for \"'.$class_name.'\"',\n                'attempts' => $possibilities,\n                'trace' => $functions,\n            ], true));\n        }\n    }\n\n    return false;\n}\nspl_autoload_register('podlove_autoloader');\n"
  },
  {
    "path": "bootstrap/bootstrap.php",
    "content": "<?php\n\nrequire_once __DIR__.'/autoload.php';\nrequire_once __DIR__.'/constants.php';\n"
  },
  {
    "path": "bootstrap/constants.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/*\n * Conventions\n *\n * \tPlugin Name:\t\tThis Is My Plugin\n * \tPlugin Namespace:\tThisIsMyPlugin\n * \tPlugin File:\t\tthis-is-my-plugin.php\n * \tPlugin Textdomain:\tthis-is-my-plugin\n * \tPlugin Directory:\tthis-is-my-plugin\n */\n\ndefine('Podlove\\PLUGIN_FILE_NAME', strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', __NAMESPACE__)).'.php');\ndefine('Podlove\\PLUGIN_DIR', plugin_dir_path(dirname(__FILE__)));\ndefine('Podlove\\PLUGIN_FILE', PLUGIN_DIR.PLUGIN_FILE_NAME);\ndefine('Podlove\\PLUGIN_URL', plugins_url('', PLUGIN_FILE));\n\n/**\n * Get a value of the plugin header.\n *\n * @param mixed $tag_name\n */\nfunction get_plugin_header($tag_name)\n{\n    static $plugin_data; // only load file once\n\n    if (!function_exists('get_plugin_data')) {\n        require_once ABSPATH.'/wp-admin/includes/plugin.php';\n    }\n\n    $plugin_data = get_plugin_data(PLUGIN_FILE, false, false);\n\n    return $plugin_data[$tag_name];\n}\n"
  },
  {
    "path": "changelog.txt",
    "content": "= 4.0.15 =\n\n- security: add nonces to jobs management\n\n= 4.0.14 =\n\n- add: migrate episode license selector user interface\n- change: show unknown duration as \"--:--:--.---\" instead of \"00:00:00.000\"\n- fix: auto-generate file slug from episode-post-title\n- fix: ensure slug field is always usable (wide enough, and prefix shortened if necessary)\n- security: fix SQL injection vulnerability in Related Episodes module\n- security: ensure only administrators can manage jobs\n\n= 4.0.13 =\n\n**Features**\n\n- Templates: new `active` accessor for `File` objects. Returns if the file is marked as active.\n\n**Bugfixes and Improvements**\n\n- fix: don't use the Auphonic chapter image URL (real fix where chapter images are downloaded and served from WordPress will follow later)\n- fix: only display active files in download widgets\n- improve handling of upload directory\n\n= 4.0.12 =\n\n**Security**\n\n- fix SSRF vulnerability in Slacknotes module\n- add missing capability check and nonce validation to importer functions\n- add missing capability check and nonce validation to exporter functions\n\n= 4.0.11 =\n\n- new: show admin notice when a database migration fails\n- fix bug where tracking data could be lost by disabling a media file checkbox\n- fix bug where imported Hindenburg chapters were not sorted by time\n- fix build script (correctly delete all vendor prefixed dependencies)\n- fix deprecation warnings ([#1431](https://github.com/podlove/podlove-publisher/pull/1431), [#1430](https://github.com/podlove/podlove-publisher/pull/1430))\n- update js dependencies\n- update help text for missing curl module\n\n= 4.0.10 =\n\n- fix security issues (XSS)\n- do not unnecessarily flush rewrite rules ([Issue#1432](https://github.com/podlove/podlove-publisher/issues/1432))\n- fix link to Slacknotes and Subscribe Button documentation\n- fix psr library not removed after prefixing ([Issue#1421](https://github.com/podlove/podlove-publisher/issues/1421))\n\n= 4.0.9 =\n\n**Enhancements**\n\n- trim whitespaces from beginning and end of file slug\n- soundbite: change placeholder to HH:MM:SS to clarify format\n\n**Bugfixes**\n\n- fix division by zero in analytics\n- fix default contributors missing position attribute\n- fix Auphonic chapter timestamp import\n- fix page reload when clicking chapter upload button\n\n= 4.0.8 =\n\n**Bugfixes**\n\n- fix broken analytics episode screen\n\n= 4.0.7 =\n\n**Bugfixes**\n\n- fix media verification not saving\n- fix shownotes unfurling\n- avoid failure during database migration\n\n**Misc**\n\n- update/cleanup various js dependencies\n\n= 4.0.6 =\n\n**Bugfixes**\n\n- Auphonic: saving production not working when slug is not set (bug introduced in 4.0.5)\n\n= 4.0.5 =\n\n**Bugfixes**\n\n\n- Auphonic: restore previous behaviour:\n  - automatically fill in file slug, validate media files and fill in duration when production finishes\n  - use slug as \"output_basename\" if it is set\n\n**Misc**\n\n- cleanup legacy js app (dependency updates, deletion of unused code etc.)\n\n= 4.0.3 / 4.0.4 =\n\n**Enhancements**\n\n- Auphonic: sort presets alphabetically\n- Contributors: make better use of available space\n\n**Bugfixes**\n\n- episode metadata not saving reliably for some people\n- Auphonic: fix chapter time import\n- WordPress File Upload: display slug input (should be filled automatically but does not seem to work reliably)\n\n= 4.0.2 =\n\n**Bugfixes**\n\n- Auphonic: Chapters can be imported from production metadata\n- Contributors: Add support for Gravatar and default contributor image on edit screens\n- Dashboard: Asset Validation / Detection is working again [#1396](https://github.com/podlove/podlove-publisher/issues/1396)\n- Automatic Numbering: error when selecting a show\n\n= 4.0.1 =\n\n**Enhancements**\n\n- Auphonic: autosave before \"Start Production\" so it is not required to explicitly save before starting\n\n**Bugfixes**\n\n- Auphonic: open productions with missing algorithm information\n- Templates: fix broken core templates `downloads-select` and `related-episodes-list`\n- Classic Editor: display Episode Title Placeholder based on Blog Post Title\n\n= 4.0.0 =\n\nPodlove Publisher 4.0 is here, bringing a spring-clean (in November!) of the episode page. We tore up the foundation to bring you an all-new user interface.\n\n**Warning:** PHP 8.0 and above is now required!\n\n**Highlights**\n\n- **Episode Form** User Interface is modernised and auto-saves, so no work is ever lost.\n- **Auphonic module** includes Multitrack Support.\n- **New Contributors** can be added without leaving the episode page.\n- **Chapters** support images.\n- **REST API V2** is now, including many more endpoints. See the [API documentation](https://docs.podlove.org/podlove-publisher/api) for all the details.\n\n\n**Tidbits**\n\n- file “slug” field is prefixed with the media location so it’s more obvious what it is used for\n- episode duration is always auto-detected and not an input field any more\n- Auphonic Preset can be selected directly in the episode and does not rely on global module setting any more\n- removed module “Twitter Card Integration” (RIP). By the way, if you want to follow us on social media, find us here: https://fosstodon.org/@podlove\n- fix various PHP notices and warnings to be PHP 8.0+ compatible\n\n= 3.8.11, 3.8.12 =\n\n* fix: templates XHR issue in WP MultiSite\n* fix: lists UI in WP MultiSite\n\n= 3.8.10 =\n\n* fix: templates UI in WP MultiSite\n\n= 3.8.9 =\n\n* fix broken encoding when saving episode meta\n\n= 3.8.8 =\n\n* fix broken RSS feeds\n\n= 3.8.7 =\n\n* improve PHP 8 compatibility\n  * stop using FILTER_SANITIZE_STRING\n  * set default encoding in DOMDocument\n  * set default values in transcript template internals\n\n* fix(plus): gracefully handle when things go wrong in image generator\n* fix(shownotes): handle image providers returning lists\n* fix subtle `get_the_excerpt` bug (uses post parameter now instead of the global post object)\n\n= 3.8.6 =\n\n* fix incompatibilities with matomo plugin ([#1308](https://github.com/podlove/podlove-publisher/pull/1308))\n\n= 3.8.5 =\n\n* fix PHP notice on episode screen\n\n= 3.8.4 =\n\n* fix CSRF vulnerabilities by adding nonces to forms\n\n= 3.8.3 =\n\n* fix XSS vulnerability\n\n= 3.8.2 =\n\n* add support for op3.dev using the \"External Analytics\" module\n* update OPAWG data (for download analytics / user agent detection)\n* improve PHP 8.1 compatibility\n\n= 3.8.1 =\n\n* fix(api): episode title -- Episode title in API now follows the same rules as in RSS feed. There's a new field 'title_clean' for accessing the specifically set plain episode title, but that might be null in some cases, so it's better to default the 'title' attribute to the usual rules.\n* fix: auphonic preset for show applies after saving the episode\n* fix: remove debug error log for a shows analytics query\n\n= 3.8.0 =\n\n**New module: Automatic Numbering**\n\nAutomatically increase the Episode number when creating episodes.\nWhen using the \"Shows\" module, each show has its own numbering.\n\nThis module is active by default for new setups.\nTo use it in your current setup, go to `Podlove > Modules`, search for \"Automatic Numbering\" and activate it.\n\n**Other Changes:**\n\n- Shows Module: each show can define its own Auphonic production preset\n- episode: after file revalidation, auto-detect duration\n- contributors: assign a `Sponsor` role and it will appear as `<podcast:person ... role=\"sponsor\">...` in the RSS feed\n- fix: correct PHP version number in message (https://github.com/podlove/podlove-publisher/pull/1272)\n- fix: feed cache issue when using the \"Shows\" module\n\n= 3.7.0 =\n\n**Shownotes**\n\nThe Shownotes module helps you manage link based show notes to display on your website and podcatchers.\n\nThe module UI has been rewritten and streamlined for efficient workflows.\nA new UI element was added to allow for quickly sorting long lists of links into topics:\nWhenever a link is dragged, a floating list of all topics appears next to the cursor.\nThe link can then be dropped under the desired topic there instead of scrolling through the whole list of shownotes.\n\nDisclaimer: URL metadata detection uses a service hosted at [plus.podlove.org](https://plus.podlove.org). It is currently available for all users of Podlove Publisher. In the future, metadata detection may only be availabe to Publisher PLUS users as it requires infrastructure to run. The rest of the Shownotes functionality will stay available to all Podlove users as usual.\n\nDocumentation: https://docs.podlove.org/podlove-publisher/modules/shownotes\n\n**Contributors**\n\n* Notifications: add \"always send to...\" section. Contributors selected there will always receive update notifications.\n* Avatars: default avatar is now a static svg instead of Gravatar (can be customized using the WordPress Filter `podlove_default_contributor_avatar_url`)\n\n**Enhancements for creating Auphonic productions** (thanks [lumaxis](https://github.com/lumaxis)!):\n\n* when the episode title is set, send this instead of the post title ([#1240](https://github.com/podlove/podlove-publisher/pull/1240))\n* send the episode number as track number ([#1240](https://github.com/podlove/podlove-publisher/pull/1240))\n* when the post thumbnail is configured as cover image, use it as direct fallback  ([#1241](https://github.com/podlove/podlove-publisher/pull/1241))\n\n**Webhooks**\n\nDefine a webhook that gets triggered every time an episode updates.\n\nThe webhook is a `POST` request with an `event` parameter and a `payload`.\n`event` is the webhook name (\"episode_updated\"), `payload` is a serialized\nJSON object of the current episode.\n\nConfiguration:\n\n    # wp-config.php\n    define('PODLOVE_WEBHOOKS', [\n        'episode_updated' => 'https://example.com/webhook-endpoint'\n    ]);\n\n**Other Changes**\n\n* soundbites: add title field ([#1257](https://github.com/podlove/podlove-publisher/pull/1257), [#1237](https://github.com/podlove/podlove-publisher/issues/1237))\n* allow detection of episode duration on mp4 ([#1249](https://github.com/podlove/podlove-publisher/pull/1249))\n* update OPAWG data (for download analytics / user agent detection)\n\n**Fixes**\n\n* fix: parameters for shortcode `[podlove-episode-contributor-list]` ([#1233](https://github.com/podlove/podlove-publisher/issues/1233))\n* fix: PHP 8 warnings ([#1258](https://github.com/podlove/podlove-publisher/issues/1258))\n* fix: deleting an episode deletes its transcript from the database ([#1252](https://github.com/podlove/podlove-publisher/issues/1252))\n* fix(contributors): notification test email ([#1247](https://github.com/podlove/podlove-publisher/issues/1247))\n* fix(analytics): filtering of httprange requests with one or two bytes ([#1243](https://github.com/podlove/podlove-publisher/issues/1243))\n* fix(image cache): redirect to source URL if image can't be downloaded into the cache\n\n= 3.6.1 =\n\n* fix: sql issue when creating the episode database tables\n\n= 3.6.0 =\n\n**New Module: Soundbite**\n\nAdds support for the `<podcast:soundbite>` RSS feed tag.\nThe intended use includes episodes previews, discoverability, audiogram generation, episode highlights, etc.\n\nUsing this module you can specify an audio segment for each episode that can be read by for example audiogram generation services.\n\n**New Module: WordPress File Upload**\n\nIf you are using WordPress Media as storage for your Podlove assets, this new\nmodule adds conveniences.\n\nFirst, you define a `Upload subdirectory` for your Podlove assets. This overrides\nany WordPress settings, so for example you can safely enable the typical date/month\nstructure for WordPress attachments and it will not affect your Podlove Uploads.\n\nThen you can update your \"Podlove - Media - Upload Location\" setting. You can keep\nit empty to let Podlove Publisher take care of it, or set it yourself if you have\na custom file hostname.\n\nNow there is an \"Upload Media File\" button in your episode form above\nthe \"Episode Media File Slug\" where you can directly upload your files.\nIf you are using multiple assets, you can upload them all there. Just make sure\nthey all have the same filename (except the file extension) before you upload.\n\n= 3.5.5, 3.5.6 =\n\n**Fixes**\n\n* SECURITY: sql injection in \"Social & Donations\" module\n* transcript API returns list again\n* PLUS open graph images (use new API)\n* handle webvtt voice, missing Contributors\n* related episodes: remove whitespace in shortcode HTML to fix rendering in Spotify\n\n**Changes**\n\n* webvtt transcripts use public contributor name\n* transcript voices / contributors:\n  * you can now select \"none\" in the voice assignment\n  * only voices with an assigned contributor (and not \"none\") appear in public transcripts\n* generate default copyright claim if it is not explcitly set\n\n= 3.5.4 =\n\n* adds copyright field in \"Podcast Settings - Directory\", which is apparently required by the Apple Podcast Directory since yesterday.\n* perf: remove frontend.js (inline logic to download button HTML)\n\n= 3.5.2 / 3.5.3 =\n\nThis releases reverses all changes to Permalinks in releases 3.5.0 and 3.5.1.\n\nI severely underestimated the effect these changes would have and revert all changes until I find a better solution. It’s simply not acceptable to change episode URLs, especially without an option for automatic redirects.\n\nPlease verify your episode URLs and the two expert settings “Permalink structure for episodes” and “Episode Pages”.\n\nWhat to do if you have used the “PODLOVE_ENABLE_PERMALINK_MAGIC” constant? It has no effect any more and you can safely remove it from your config file.\n\nWhat happened to the “Simple Episode Permalink” setting from release 3.5.1? It has been removed, too.\n\nSorry for the trouble.\nHappy podcasting :)\n\n**Other**\n\n* fix: remove usage of PHP 7.1 syntax in one file\n\n= 3.5.1 =\n\n= 2021-04-14 =\n\n* add: expert setting to make episode permalinks `/%postname%/`\n* add: include Publisher Database Version in system report\n* drop WordPress version requirement to 4.9.6\n\n= 3.5.0 =\n\n**Breaking Change**\n\nRemoves two expert settings:\n\n* \"Permalink structure for episodes\" and\n* \"Episode pages\"\n\nThese settings allowed to define custom URL structures for episodes and the episode archive.\nHowever they have caused trouble for a long time (see [#1038](https://github.com/podlove/podlove-publisher/issues/1038))\nand the only viable way out seems to remove them.\n\nHow does that affect you?\n\nIf you have never touched these settings, feel free to shrug, smile and move on.\n\nIf you _are_ using these settings, I encourage you to consider not using them as they are mostly of cosmetic nature.\nShould you however prefer to keep everything as is (including the known bugs of erratically broken permalinks / URLs), you can\nenable the settings back with a single line of code in your wp-config.php:\n\n    `define('PODLOVE_ENABLE_PERMALINK_MAGIC', true);`\n\n**Experimental: Full-Page Podlove Templates**\n\nIf you want to create a 100% custom page based on an episode but without all the WordPress theme around, this is for you.\nPossible use case: A dedicated page to print the episode transcript.\n\n1. create a new Podlove Template, for example `page-episode-transcipt`\n2. Write that transcript as a _full HTML page_. That means it starts with `<!doctype html><html>` and ends with `</html>`!\n3. Append `?podlove_template_page=page-episode-transcipt` to your public episode URL. For example if your episode is `https://example.com/ep001/`, then open `https://example.com/ep001/?podlove_template_page=page-episode-transcipt`\n\nVery simple example template:\n\n    <!doctype html>\n    <html>\n\n    <head>\n      <meta charset=\"utf-8\">\n      <title>Transcript | {{ episode.title }} | {{ podcast.title }}</title>\n      <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    </head>\n\n    <body>\n\n      <p>\n        Here's the transcript for podcast <strong>{{ podcast.title }}</strong> episode <strong>{{ episode.title }}</strong>:\n      </p>\n\n      [podlove-transcript]\n\n    </body>\n\n    </html>\n\nEnjoy!\n\n**Shownotes Module**\n\n* provide website screenshot as fallback when no sharing image is available (requires PLUS token)\n* show images\n* show image in edit view\n* when importing, show all entries\n* show import progress when unfurling\n* fix osf importer\n* fix encoding issue when importing from HTML\n\n**Miscellaneous**\n\n* update database for podcast user agents -- notably includes classification of Apple Watch downloads as bot [#1203](https://github.com/podlove/podlove-publisher/issues/1203)\n* transcript: add some basic info about podcast and episode into webvtt as a note\n* analytics: add hook `podlove_useragent_opawg_data` to add custom user agent detection\n* Podlove Templates: add `dataUri` method to images. Takes same arguments as `url` but returns a data uri. Useful if you want to generate a self-contained HTML page. If you're not sure, better use `url`.\n* fix: transcripts with trailing newlines don't confuse the importer\n* fix: don't count contributors multiple times if they have multiple contributions in an episode ([#1200](https://github.com/podlove/podlove-publisher/issues/1200))\n* fix: calling wptexturize too early ([#1194](https://github.com/podlove/podlove-publisher/issues/1194))\n\n= 3.4.1 =\n\n* fix: analytics shows section now does not include other taxonomies\n* use image caching for shownotes images\n* analytics shows section is now ordered by downloads\n\n= 3.4.0 =\n\n**podcastindex namespace**\n\nBoth additions add metadata to the feed automatically if the data is present. No new user interfaces or data entry is necessary.\n\n* add support for feed tag [`podcast:transcript`](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript), linking to the transcript in various formats (json, webvtt, xml)\n* add support for feed tag [`podcast:person`](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person) on episode level\n\n**analytics**\n\n* for selected date range, total downloads are shown\n* for selected date range, display downloads per show (only visible when shows module is enabled)\n\n= 3.3.2 =\n\n* fix: in analytics, the \"Export as CSV\" section is now clickable when global statistics are loading or have no data\n* fix: \"Export as CSV\" works again\n* fix: \"global statistics\" charts idling indefinitely until a custom date range is chosen\n\n= 3.3.0 / 3.3.1 =\n\n* add support for feed tag [`podcast:funding`](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#funding) (see Podcast Settings -> Directory)\n* unfurl uses https://plus.podlove.org/api/unfurl as API endpoint\n* add banner linking to donations page (can be dismissed)\n* shownotes:\n  * add shortcode `[podlove-episode-shownotes]`\n  * display links even if unfurling failed\n  * template improvements\n  * add \"delete all\" button\n  * polished failure section UI and allow editing original URL\n  * API: add missing permission callbacks\n  * fix: keep order when importing via slacknotes\n* slacknotes: update to new API\n* change donation URL to https://opencollective.com/podlove\n* fix: handle missing templates in TwigLoaderPodloveDatabase\n\n= 3.2.2 =\n\n* fix: crash when creating new episodes\n\n= 3.2.1 =\n\n* fix: coverart url encoding [#1181](https://github.com/podlove/podlove-publisher/pull/1181)\n* fix: some settings not applying to episode title tag (thanks Dirk)\n* fix: crash when accessing season data for an episode without season\n\n= 3.2.0 =\n\n* when automatically generated episode titles are used, use the blogpost title as fallback for the episode title\n* fix: disable slug auto-updating after importing from Auphonic\n* fix: webvtt-parser autoloading issues [#1175](https://github.com/podlove/podlove-publisher/issues/1175)\n* fix: escape ampersands in itunes:image hrefs in the feed [#1176](https://github.com/podlove/podlove-publisher/issues/1176) (fixes incompatibilities with Jetpack image CDN)\n\n= 3.1.* =\n\n* fix twig namespace prefixing related issues\n* remove unused vendor-bin directory from releases\n\n= 3.1.1 =\n\n* tracking: fix operating systems appearing twice in different spellings\n* chore: prefix all composer packages (solves Twig related incompatibilities & crashes)\n* chore: add content and files to episodes api (#1165)\n\n= 3.1.0 =\n\n* analytics: new chart showing download development from episode to episode [#1100](https://github.com/podlove/podlove-publisher/pull/1100/files) thanks [@poschi3](https://github.com/poschi3)!\n* Auphonic: show production warnings in module (https://twitter.com/auphonic/status/1305849345762185217)\n* download tracking: use OPAWG podcast user agent database in addition to Matomo database\n* stability: detect plugins using older/incompatible versions of Twig. Display a warning on the site (instead of an error) and a detailed explanation on \"Podlove > Support\" screen.\n* enhance: podcast file validation in dashboard includes all post stati and checks for missing slug [#1161](https://github.com/podlove/podlove-publisher/pull/1161)\n* enhance: only allow episode numbers of 0 and higher in form input [#1158](https://github.com/podlove/podlove-publisher/pull/1158)\n* api: add public endpoint for transcripts\n* api: add public endpoint for shownotes\n* fix: Podlove Web Player 5 includes all downloadable assets in download section\n* fix: transcript API URL [#1145](https://github.com/podlove/podlove-publisher/pull/1145) thanks [gibso](https://github.com/gibso)!\n* fix: editing/deleting shows ([#1077](https://github.com/podlove/podlove-publisher/issues/1077))\n* fix: episodes and shows API\n* fix: migration for Shownotes only when the database table exists\n\n= 3.0.4 =\n\n* fix: contributor notifications settings can be saved again ([#1144](https://github.com/podlove/podlove-publisher/issues/1144))\n* fix: do not include invisible contributors in Web Player 5 API ([#1142](https://github.com/podlove/podlove-publisher/issues/1142))\n* fix: detect Yoast SEO, wpSEO: disables Open Graph module ([#1132](https://github.com/podlove/podlove-publisher/issues/1132))\n* fix: use podcast summary as RSS Feed `<description>` if subtitle is not set ([#1092](https://github.com/podlove/podlove-publisher/issues/1092))\n\n= 3.0.3 =\n\n* fix: title escaping in RSS feed when using native (not auto-generated) titles\n\n= 3.0.2 =\n\n* add: Untappd social service\n* fix: Auphonic module (wrong HTTP API headers)\n* chore: update npm dependencies\n\n= 3.0.1 =\n\n* fix: escaping issue in RSS feed (itunes:author and itunes:owner)\n* fix: remove (rare) accidental double enclosure tag in RSS feed when \"enclosure\" post meta is present\n\n= 3.0.0 =\n\n**Breaking Changes**\n\n* requires PHP 7.0 (or newer)\n* requires WordPress 5.2 (or newer)\n* Web Player:\n  * removes Podlove Web Player 2\n  * removes Podlove Web Player 3\n  * removes \"insert player automatically\" option (probably does not affect anyone as the web player is by default inserted via template)\n  * removes \"Chapters Visibility\" option (use dedicated Web Player settings instead)\n\n**New Publisher PLUS**\n\n=> [plus.podlove.org](https://plus.podlove.org/)\n\nPublisher PLUS is a new service providing Feed Proxy and Podcast Subscriber statistics for Podlove Publisher.\n\nTo use it, enable the *Publisher PLUS* module, then visit [plus.podlove.org](https://plus.podlove.org/) to create an account.\n\nSubscriber Statistics are only the beginning. Expect more features soon!\n\n**Experimental: Shownotes**\n\nGenerate and manage episode show notes. Helps you provide rich metadata for URLs. Full support for Publisher Templates.\n\nThis module is a work-in-progress. But it's usable, so feel free to give it a try, especially if your shownotes are link-heavy and you're comfortable writing Podlove (Twig) templates.\n\nThe module is currently hidden. Make it visible by setting a PHP constant, for example in your `wp-config.php`: `define('PODLOVE_MODULE_SHOWNOTES_VISBLE', true);`.\n\nUse this template as a starting point: https://gist.github.com/eteubert/d6c51c52372dc2da2f1734a5f54c7918\n\n**Shortcodes**\n\n* `podlove-episode-contributor-list`\n  * new design\n  * renders text-only in RSS feed\n* `podlove-podcast-contributor-list`\n  * new design\n* `podlove-episode-downloads`\n  * the text link variant is now the default style\n\n**Miscellaneous**\n\n* remove Bitlove module (service does not exist any more)\n* remove Flattr module\n* remove \"Website Protocol\" setting (not necessary any more as Let's Encrypt is widely supported)\n* enable episode chapters by default\n* convenience: \"Copy to Clipboard\" function for Podlove Template shortcodes\n* expose iTunes id/URL in podcast feed ([#1078](https://github.com/podlove/podlove-publisher/pull/1078))\n* improve feed rendering: use XML generator for all tags with user input to guarantee valid feeds for all inputs\n* add function to remove a transcript from an episode ([#1131](https://github.com/podlove/podlove-publisher/issues/1131))\n* add Steady as donation service\n* add template tag: `episode.post_title` ([#1136](https://github.com/podlove/podlove-publisher/issues/1136))\n* add template tag: `service.type` (https://community.podlove.org/t/replacing-social-icons/2321)\n* add default avatar to transcript preview\n* fix: search logic ([#1072](https://github.com/podlove/podlove-publisher/issues/1072))\n* fix: fetch Podlove News via https ([#1037](https://github.com/podlove/podlove-publisher/issues/1037))\n* fix: don't send Publisher logs to system log when WP_DEBUG is on ([#1065](https://github.com/podlove/podlove-publisher/issues/1065))\n* fix: ensure uploads for webvtt (transcripts) and gz (exports) are allowed\n* fix: ensure contributors module is active when transcripts are used\n* fix: ensure permissions in shownotes and transcripts APIs\n* fix: don't count download requests with http range header of `bytes=0-0` ([#1135](https://github.com/podlove/podlove-publisher/issues/1135))\n* update dependencies\n* build releases with GitHub Actions (in favour of TravisCI)\n\n= 2.11.4 =\n\n* fix: missing monolog dependency\n\n= 2.11.2 =\n\n* fix: ensure that logging library Monolog is available at version 1.x, otherwise disable the database logger\n\n= 2.11.1 =\n\n* Podlove Web Player 5: support \"show\" parameter `episode.player({show: 'my-show-slug'})`\n\n= 2.11.0 =\n\n* add global network bar [#1101](https://github.com/podlove/podlove-publisher/pull/1101) by [@gglnx](https://github.com/gglnx)\n* improve template editing UI [#1109](https://github.com/podlove/podlove-publisher/pull/1109)\n* fix: template tag `episode.player` uses correct shortcode internally\n* fix: template tag `episode.player` uses correct episode on pages that are not its own episode-page\n\n= 2.10.0 =\n\nAdd support for Podlove Web Player 5\n\nPodlove Web Player 5 is the latest overhaul of our podcast web player.\nIt comes with its own configuration interface giving you full control over its appearance.\n\nActivate it in `Podlove > Podcast Settings > Player`.\nYou are then prompted to install the \"Podlove Web Player\" plugin if you don't have it installed already.\n\nConfigure the web player appearance in `Settings > Podlove Web Player`.\nExisting web player shortcodes and template accessors continue to work as expected.\nFor detailed shortcode options, please refer to https://wordpress.org/plugins/podlove-web-player/\n\n= 2.9.10 =\n\n* when using Google Analytics tracking, the show title is sent as content group\n\n= 2.9.9 =\n\nRe-Release of 2.9.8\n\n= 2.9.8 =\n\n* add Twig function `get_the_post_thumbnail_url` identical to the native WordPress function\n* fix Podlove Web Player 4 issue in twentytwenty theme\n* fix some importer issues\n* shows module: itunes category can be set per show\n\n= 2.9.7 =\n\n* update JavaScript dependencies\n\n= 2.9.6 =\n\n* update PHP dependencies (including User Agent library for download analytics)\n* add: expose voice attribute to transcript templates ([#1062](https://github.com/podlove/podlove-publisher/pull/1062))\n* add(templating): add sort direction in seasons and season episodes, enabling `podcast.seasons({order: 'DESC'})` and `season.episode({order: 'DESC'})` ([#1080](https://github.com/podlove/podlove-publisher/issues/1080))\n* fix: download list description in analytics on mobile ([#1056](https://github.com/podlove/podlove-publisher/issues/1056))\n* fix: JS issue when selecting transcript voices\n* fix: escaping error in contributor comments ([#1081](https://github.com/podlove/podlove-publisher/issues/1081))\n\n= 2.9.5 =\n\n* Slacknotes: reactivate date picker\n\n= 2.9.4 =\n\n* fix: error on \"file types\" settings page\n\n**IAB Conformity**\n\nWhen it comes to tracking download intents, Podlove Publisher was always close to IAB recommendations, with one exception: the time window in which two requests count as two. Podlove Publisher deduplicates by hour, IAB recommends a day.\n\nThere is a new setting in `Podlove > Expert Settings > Tracking`: \"Deduplication Window\". It enables you to change the window to \"day\". This is an opt-in setting, the default will continue to be hourly.\n\nSee also: [docs.podlove.org: IAB Conformity](https://docs.podlove.org/podlove-publisher/guides/download-analytics.html#iab-conformity)\n\nThis feature is sponsored by [Lage der Nation](https://lagedernation.org).\n\n= 2.9.3 =\n\n* add quick edit for episode number [#1096](https://github.com/podlove/podlove-publisher/pull/1069)\n* fix settings tab issues when using a language in WordPress other than english ([e613e99](https://github.com/podlove/podlove-publisher/commit/e613e99bb4f07bb88234146567e76d21ce06f5ff))\n* fix issue with category search / pages\n* fix auphonic module issue in Gutenberg editor\n\n= 2.9.2 =\n\n* update Podlove Web Player (fixes issue when sharing/embedding the player)\n* fix PHP notices [#1066](https://github.com/podlove/podlove-publisher/issues/1066) [#1064](https://github.com/podlove/podlove-publisher/issues/1064)\n\n= 2.9.1 =\n\n* fix web player sharing when using CDN player\n* fix duplicating posts: create new guid; do not copy analytics [#1048](https://github.com/podlove/podlove-publisher/issues/1048)\n\n= 2.9.0 =\n\n**New Apple iTunes Categories**\n\nApple updated their list of available iTunes categories.\nPlease check in `Podlove > Podcast Settings > Directory > iTunes Category` if you need or want to update your category.\nIn case your previously selected category does not exist any more, a warning is shown.\n\nOnly one category is selectable now (instead of previously 3) to conform with iTunes specifications.\n\n**Download tracking with Google Analytics**\n\nSet your Google Analytics Tracking ID in Podlove > Expert Settings > Tracking.\nThen every download intent will be forwarded to Google Analytics.\n\n[#1058](https://github.com/podlove/podlove-publisher/pull/1058)\n\n**Other**\n\n* fix: check if podlovePlayer function is available before calling it [#1060](https://github.com/podlove/podlove-publisher/pull/1060)\n\n= 2.8.10 =\n\n* update Podlove Web Player 4 to latest version\n\n= 2.8.9 =\n\n* update Podlove Web Player 4 to latest version\n* remove PHP dependency leth/ip-address\n\n= 2.8.8 =\n\n* update Podlove Web Player 4 to latest version\n\n= 2.8.7 =\n\n* update Podlove Web Player 4 to latest version\n* add player setting to either use the podcast language or user's browser language for web player interface ([#1008](https://github.com/podlove/podlove-publisher/pull/1008))\n* fix [#1047 Use of PHP 5.6 feature in Shows module](https://github.com/podlove/podlove-publisher/issues/1047)\n* report duplicate guids in system report\n\n= 2.8.0 =\n\n**Transcripts**\n\n“Transcripts” is the new module to manage transcripts, show them on your site and in the web player. You can import them from webvtt files. If you are already using the Podlove Publisher contributors, you can assign people to the voices inside the webvtt. Then you even get avatars automatically in your transcripts.\n\nSee [https://forschergeist.de/podcast/fg066-klimaneutralitaet/](https://forschergeist.de/podcast/fg066-klimaneutralitaet/) for an example episode with transcripts in the web player.\n\n**Transcripts: Shortcode**\n\nThe shortcode `[podlove-transcript]` displays a pretty html version of the transcript for your website.\n\n**Transcripts: Twig Template Support**\n\nOf course there is a fully featured template API for transcripts as well. For example:\n\n{% for group in episode.transcript %}\n    <div class=\"ts-group\">\n\n        <div class=\"ts-speaker-avatar\">\n            {{ group.contributor.image.html({width: 50}) }}\n        </div>\n\n        <div class=\"ts-text\">\n            <div class=\"ts-speaker\">\n                {{ group.contributor.name }}\n            </div>\n\n            <div class=\"ts-content\">\n                {% for line in group.items %}\n                <span class=\"ts-line\">{{ line.content }}</span>\n                {% endfor %}\n            </div>\n        </div>\n\n    </div>\n{% endfor %}\n\nSee [https://docs.podlove.org/podlove-publisher/reference/template-tags.html](https://docs.podlove.org/podlove-publisher/reference/template-tags.html \"documentation\") for all details.\n\n**Global Podcast Analytics**\n\nThe following metrics are now available for the whole podcast:\n\n- downloads per month\n- top episodes\n- episode asset\n- podcast client\n- operating system\n- download source\n\n**Raw Analytics**\n\nI wouldn’t call this an Analytics API but since it exists to power the analytics screen, I might as well document it. The following endpoints return results in CSV format for easy processing or import to spreadsheets.\n\nHere is an example call that returns the number of downloads in March 2019:\n\n\thttps://your.domain/wp-admin/admin-ajax.php?action=podlove-analytics-global-downloads-per-month&date_from=2019-03-01T00:00:00.000Z&date_to=2019-03-31T23:59:59.999Z\n\nAll requests take the same three parameters:\n\n- `action` defines what data you want\n- `date_from` is the start date in ISO 8601\n- `date_end` is the end date in ISO 8601\n\nAvailable actions are:\n\n- podlove-analytics-global-downloads-per-month\n- podlove-analytics-global-top-episodes\n- podlove-analytics-global-assets\n- podlove-analytics-global-clients\n- podlove-analytics-global-systems\n- podlove-analytics-global-sources\n\nYou need to be logged in with admin permissions for the requests to work.\n\nDisclaimer: Depending on the popularity of your podcast and chosen date range, the requests may take a long time to respond, or even fail if the calculation takes longer than the timeout defined in your web server.\n\n**Other**\n\n- background jobs: add button to abort job\n- new tab style for chapter marks section\n- Podlove Web Player 4 fallback for old browsers and disabled JavaScript\n\n= 2.7.24 =\n\n* update Podlove Web Player 4\n\n= 2.7.23 =\n\n* **slacknotes:** is now accessible to authors and editors\n* update some PHP dependencies (including Twig)\n* add ability to specify visible components in Podlove Web Player V4 ([#1032](https://github.com/podlove/podlove-publisher/pull/1032))\n\n= 2.7.22 =\n\n* (maybe) fix Gutenberg issues when creating a new episode\n\n= 2.7.21 =\n\n**Bug Fixes**\n\n* **slacknotes:** avoid duplicate vue for-loop keys ([7578cdf](https://github.com/podlove/podlove-publisher/commit/7578cdf))\n* **slacknotes:** date range filter ([2982f2b](https://github.com/podlove/podlove-publisher/commit/2982f2b))\n* **slacknotes:** fix loading of datepicker component ([13ca12b](https://github.com/podlove/podlove-publisher/commit/13ca12b))\n* **slacknotes:** follow redirects when resolving URLs ([5b39746](https://github.com/podlove/podlove-publisher/commit/5b39746))\n* **slacknotes:** handle slack-resolved URLs in pipes format ([b08ec53](https://github.com/podlove/podlove-publisher/commit/b08ec53))\n* **slacknotes:** hide link-fetch prompt while fetching ([f4e78e3](https://github.com/podlove/podlove-publisher/commit/f4e78e3))\n* **slacknotes:** url re-fetching when changing dates ([344456d](https://github.com/podlove/podlove-publisher/commit/344456d))\n\n\n**Features**\n\n* **slacknotes:** add setting for link ordering ([c4c824e](https://github.com/podlove/podlove-publisher/commit/c4c824e))\n* **slacknotes:** show link time ([53077b1](https://github.com/podlove/podlove-publisher/commit/53077b1))\n* **slacknotes:** when resolving URLs, use effective URL ([974c7f8](https://github.com/podlove/podlove-publisher/commit/974c7f8))\n\n= 2.7.20 =\n\n**Slacknotes**\n\nThis release is sponsored by [Lage der Nation](https://lagedernation.org).\n\nThe new \"Slacknotes\" module extracts links and their metadata from a Slack channel and generates HTML that can be used as show notes.\nA short demo video is available [in the documentation](https://docs.podlove.org/podlove-publisher/guides/slacknotes.html).\n\n**Other**\n\n* the \"Modules\" screen has been redesigned\n* updated JavaScript and CSS processing library and other dependencies\n\n= 2.7.19 =\n\nWe are now compatible to the new WordPress 5.0 Gutenberg block editor.\nYou can choose to use the new editor or stay with the classic editor for now by installing the classic editor plugin by WordPress.\n\n* feed: do not include `<itunes:summary>` tag if it is empty (Apple Podcast requirement)\n* adjustments for Gutenberg compatibility:\n  * Shows metabox moved from sidebar to main area\n  * remove broken form field autogrow behavior\n  * fix contributors UI initialization\n\n= 2.7.18 =\n\n* improve feed generation time when seasons are used ([#1010](https://github.com/podlove/podlove-publisher/issues/1010))\n* title migration module:\n  * remove \"episode type\" selector, always use \"full\"\n  * add warning when there might be too many form fields\n* new PHP constant `PODLOVE_DISABLE_TAG_AND_CATEGORY_SEARCH` ([#1017](https://github.com/podlove/podlove-publisher/issues/1017))\n* feed item limit: \"1\" is now an option\n* add missing contributor template accessors: organisation, department, jobtitle\n* ensure Gutenberg editor is not used for episodes\n\n= 2.7.17 =\n\n**Downloads Data Export**\n\nDownload data per episode can now be exported as JSON and CSV.\nOn the Analytics page you will now find a simple export interface.\nSelect the episodes you want in the export or don't select any to export them all at once.\n\n**WP REST API Support**\n\nBackbone for the data export is an implementation of the WP REST API.\n\nEndpoint for the episode custom post type:\n\n- /wp-json/wp/v2/episodes\n\nCustom endpoints for episode analytics:\n\n- /wp-json/podlove/v1/analytics/episodes/\n- /wp-json/podlove/v1/analytics/episodes/123\n- /wp-json/podlove/v1/analytics/episodes/123,82\n\nAll analytics are available as CSV by adding `?format=csv` as parameter, for example `/wp-json/podlove/v1/analytics/episodes/?format=csv`\n\nAnalytics endpoints require the `podlove_read_analytics` permission, the same as viewing analytics in the admin.\n\nPlease read https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/ if you want to use these endpoints.\n\n**Other**\n\n* Fix deprecation warning when using multiple categories ([#1009](https://github.com/podlove/podlove-publisher/pull/1009))\n\n= 2.7.16 =\n\n* update Podlove Web Player 4\n\n= 2.7.15 =\n\n* automatically abort stuck background jobs\n* contributors now appear in feeds even if they don't have a URI [#939](https://github.com/podlove/podlove-publisher/issues/939#issuecomment-430248520)\n* Shows: custom language is now used for Podlove Subscribe Button\n\n= 2.7.14 =\n\n* update Podlove Web Player 4\n\n= 2.7.12/13 =\n\n* use wp_enqueue_script instead of inline JS when calling PWP4, improving compatibility to other plugins ([#1000](https://github.com/podlove/podlove-publisher/issues/1000))\n* uninstall: be more specific which options are deleted ([#997](https://github.com/podlove/podlove-publisher/issues/997))\n* new filter `podlove_network_module_activate` to force-enable network module ([#995](https://github.com/podlove/podlove-publisher/issues/995))\n* new social services: Mastodon, Fediverse, Friendica ([#987](https://github.com/podlove/podlove-publisher/issues/987), [#968](https://github.com/podlove/podlove-publisher/issues/968))\n* fix related episodes disappearing when using post scheduling ([#980](https://github.com/podlove/podlove-publisher/issues/980))\n* fix seasons error when there are no episodes ([#963](https://github.com/podlove/podlove-publisher/issues/963))\n* related episodes: order by post date ([#947](https://github.com/podlove/podlove-publisher/issues/947))\n\n= 2.7.10/11 =\n\n* update Analytics JS frameworks, fixing [#982](https://github.com/podlove/podlove-publisher/issues/982)\n* add download location analytics chart\n* remove weekday chart\n* add option to disable average episode display\n\n= 2.7.9 =\n\nToo much slimming in 2.7.8. Undo.\n\n= 2.7.8 =\n\n* fix release workflow\n* slim down plugin size by removing unnecessary files from release\n* update Podlove Web Player 4\n\n= 2.7.7 =\n\nUpdate 2.7.5 changed the way download tracking works to comply with GDPR. We tried the radical approach and anonymized IPs. As it turns out, this is not viable. Download numbers are skewed by this change and often much lower than they realistically should be. If you saw a drop in downloads since updating to 2.7.5 or 2.7.6, this is the reason.\n\nThe good news is that this update changes download tracking again and new download numbers should get back to normal. The bad news is that the data since the GDPR update cannot be fixed/restored because it's missing data granularity -- which was the point of the change; just not anitcipating the effect on the actual download numbers.\n\nSo what's the new tracking approach?\n\nPodlove Publisher now stores the `request_id` again just like before the update: a hash based on the actual IP address and the user agent. What's new is that now once a day, all `request_id`s older than 24 hours are salted again, making it impossible to restore IPs from them. This 24 hour window is enough to determine download numbers exactly as before the GDPR update.\n\nTo be clear, IPs are never stored in plain text. But since IPs could be restored by brute force attack from the temporary unsalted `request_id` hashes, they have to be treated like plain IPs. The text snippet for your privacy page has been updated in the docs and you should update it on your site: https://docs.podlove.org/podlove-publisher/guides/dsgvo-gdpr.html\n\n= 2.7.6 =\n\nNo changes.\n\n= 2.7.5 =\n\n**Preparation for GDPR/DSGVO**\n\nIf you are using Podlove Publisher Tracking/Analytics, an update to this version is highly recommended.\n\nTracking uses a `request_id` to be able to determine when two requests came from the same user and should be counted as one unique access. This request id used to be a hash of the original IP address and the user agent. This approach however is vulnerable to a brute force attack to get the IP address back from the hash. Here's what we are doing about that:\n\nFirst, we anonymize the IP before generating the hash. So instead of using `171.23.11.209`, we use `171.23.11.0`.\n\nSecond, you need to deal with the existing `request_id`s. There is a new \"DSGVO\" section under \"Tools\" with a button that will rehash all existing `request_id`s with a randomly generated salt. That way it will become unfeasible to determine the original IP address but your analytics will stay the same.\n\nIn case you have a lot of downloads (let's say much more than 50.000), you may want to do this via command line because that will be _much_ quicker than via the tools section. You need [wp-cli](https://wp-cli.org/), then simply call `wp eval 'podlove_rehash_tracking_request_ids();'`. On a multisite, pass the blog id as a parameter: `wp eval 'podlove_rehash_tracking_request_ids(42);'`.\n\n**Other**\n\n* fix Podlove Subscribe Button language parameter\n* fix `rel=\"self\"` link in show feeds\n* fix Podlove Subscribe Button not delivering show feeds\n* templates: handle episode.show access when there is no show\n* templates: allow episode filtering by show, for example: `{% for episode in podcast.episodes({show: \"example\"}) %}`\n\n= 2.7.4 =\n\nNo changes, but the previous release is not delivered correctly by WordPress, so this is simply a re-release attempt to fix it.\n\n= 2.7.3 =\n\n* fix: geo database updater\n* update Podlove Web Player 2: remove Flash Fallback\n* update Podlove Web Player 4\n\n= 2.7.2 =\n\n* fix: `itunes:image` tag in show feeds\n* fix: \"Debug Tracking\" choosing wrong media files to check availability\n* enhancement: \"Debug Tracking\" now suggests disabling SSL-peer-verification if URL cannot be resolved and https is used\n* system report: include active plugins\n\n= 2.7.1 =\n\n* fix: PHP warning when the_title filter is called with only one parameter\n* fix: handle colons in migration tool\n* fix: PWP4 warning when using shortcode\n* new service: letterboxd\n\n= 2.7.0 =\n\n**New Module: Shows**\n\nWith shows you can offer feeds to subtopics of your podcast. Here's how it works: You create a show and define show meta, similar to a podcast: title, slug, subtitle, summary, image and language. These fields override your podcast settings. All other settings are the same as your podcast.\n\nFor each episode, you decide which show it's in. Each show has its own set of feeds that listeners can subscribe to. The main feed remains unchanged, containing all episodes from all shows.\n\nThe Podlove Subscribe Button can be configured to subscribe to a show by referencing the show slug. Use the shortcode `[podlove-subscribe-button show=\"show-slug\"]` or the template tag `{ podcast.subscribeButton({show: 'show-slug'}) }}`.\n\nWe do not recommend using Shows and Seasons at the same time.\n\n**Updated Metadata for Podcast/Episode/Seasons according to iOS11 Specification**\n\nApple announced an [updated specification for feed elements](http://podcasts.apple.com/resources/spec/ApplePodcastsSpecUpdatesiOS11.pdf). These changes enable the Apple Podcasts app to present podcasts in a better way. But since these feed extensions are readable by any podcast client, we expect others to take advantage of these new fields soon. Here is how we implemented the specification:\n\n- The podcast has a new \"type\" field where you can select between \"episodic\" and \"serial\", which may affect the order of episodes. The field `<itunes:type>episodic</itunes:type>` appears in the feed.\n- Episodes have a new \"title\" field. It defaults to the episode post title but can be set separately now, allowing you to define different titles for the website and podcast clients. The field `<itunes:title>Interview with Somebody Infamous</itunes:title>` will appear in the feed.\n- Episodes have a new \"type\" field where you can select between \"full\" (default), \"trailer\" and \"bonus\". This won't have any effect in the Publisher but may be used by podcast clients. The field `<itunes:episodeType>full</itunes:episodeType>` appears in the feed.\n- Episodes have a new \"number\" field. If used, `<itunes:episode>42</itunes:episode>` will appear in the feed.\n- Episodes in seasons will have an `<itunes:season>2</itunes:season>` field in the feed automatically.\n\nWe decided to complement these changes by introducing a podcast mnemonic/abbreviation field. Now we can autogenerate blog episode titles, based on the episode number and title, if you like. The mnemonic can be set in podcast settings. The setting to autogenerate blog episode titles is an expert setting in the \"Website\" section.\n\nTo help existing podcasts to conform to these new fields we made a \"Title Migration\" module which will greet you with a notice once you update the Publisher. It will try to extract episode numbers and titles from your existing titles, saving you time and effort updating each episode one by one.\n\n**Template API Changes**\n\n- `episode.title` now returns the new episode title field, if it is set, but has a fallback to the post title. If you want a specific version, use `episode.title.clean` or `episode.title.blog`.\n- the post title of an episode can still be accessed via `episode.post.post_title`\n- new accessor: `episode.number`\n- new accessor: `episode.type`\n- new accessor: `podcast.mnemonic`\n- new accessor: `podcast.type`\n- new accessor: `season.mnemonic`\n\n**Podlove Web Player 4**\n\nThe Shortcode `[podlove-web-player]` accepts several parameters, increasing its versatility.\n\nWith `post_id` you can embed episodes on any page, for example `[podlove-web-player post_id=\"1234\"]`.\n\nEvery [config parameter available](http://docs.podlove.org/podlove-web-player/config.html) can be overridden using shortcode attributes. The only difference from the linked documentation page is the notation. For nested configs like `show.title` use underscores (`_`) instead. For example, display a green player with custom title like this: `[podlove-web-player show_title=\"Special Title\" theme_main=\"#00ff00\"]`\n\nYou can now also display a player with _live content_ like this: `[podlove-web-player mode=\"live\" audio_0_url=\"http://mp3.theradio.cc/\" audio_0_mimeType=\"audio/mp3\" title=\"Livestream\" link=\"https://theradio.cc\"]`\n\nYou can choose to deliver Podlove Player via Podlove CDN (Content Delivery Network) or via your WordPress server. CDN is the default for new setups but if you are already using Podlove Publisher we continue delivering Podlove Player via your WordPress server unless you explicitly change it.\n\nPodlove Web Player 4 is the new default player.\n\n**Other**\n\n* analytics: show download totals for last 24 hours and last 7 days in overview\n* Podigee Player: add support for transcripts\n    - create a Podigee Transcript asset\n    - set this asset in Expert Settings > Web Player\n    - See https://cdn.podigee.com/ppp/samples/transcript.txt for an example transcript\n* Podlove Web Player 4: support contributors\n* player settings: when no episode or files are available, use a \"Podlove\" demo sound\n* reduce Podlove Template Cache duration from 1 day to 1 hour for the following change:\n* new template accessor: `{{ episode.total_downloads }}`\n* New in \"Global Feed Settings\": An option for how the episode title should be displayed. It defaults to \"Blog Post Title\", so that after the iOS 11 title migration, the output does not actually change -- following the principle of least surprise. However, the setting can be changed to \"Episode Title\", which is the new clean title, or \"Custom Template\", which is a title template with the same capabilities as the blog post title template.\n* when using the Podlove Subscribe Button CDN and the CDN is not reachable, fall back to the locally hosted script\n* fix Geo DB Updater: use our own Podlove CDN as download source\n* fix quotes in contributor fields\n* fix WordPress conditionals in episode archives\n* fix deleting related episodes ([#907](https://github.com/podlove/podlove-publisher/issues/907))\n* fix network admin bar now does not include broken links if Publisher is not activated network-wide ([#933](https://github.com/podlove/podlove-publisher/issues/933))\n* fix import getting stuck issue ([#910](https://github.com/podlove/podlove-publisher/issues/910))\n* Bitlove module: remove all frontend functionality because it has been dysfunctional for a long time\n* fix Auphonic module showing wrong status message after file upload\n* fix Audacity chapter import when times contain commas\n* fix email notification issue where not emails were sent ([#938](https://github.com/podlove/podlove-publisher/issues/938))\n* fix feed redirect issue for HTTP/1.0 clients\n* fix network module: only activate when the plugin is activated network-wide, not when the plugin in active within a multisite\n* fix calculation of contribution counts\n* Fix various issues in the download table display. Until now, new downloads were calculated hourly, which provides a good estimate but often not exact numbers. The calculation could also get stuck, leading to missing data display. From now on, the estimates are still calculated hourly but additionally _a full, precise aggregation is done once a day_, which should lead to more consistent numbers overall.\n* enhance email error reporting\n* enhance open graph module: detects WP SEO plugin and does not output any tags to avoid conflicts\n* social services: add SlideShare\n* show warning if upload directory is not fully qualified\n* remove download section from default template (because it is included in PWP4)\n* image cache: instead of returning invalid URLs with 0 width and 0 height when something goes wrong, return the source URL instead\n* episode list: add display option to display episode number as a column\n add Liberapay as donation service\n* display current season in episode form\n\n= 2.6.4 =\n\nPodlove Web Player 2:\n\n- fix: Remove Flash and Silverlight fallbacks due to security issue\n- fix: resolve compatibility issue with mediaelement library shipped with WordPress\n\nPodlove Web Player 4:\n\n- fix: newlines in summary are not converted to HTML linebreaks\n\n= 2.6.3 =\n\n- new PHP constant `PODLOVE_IMAGE_CACHE_FORCE_DYNAMIC_URL`: When `PODLOVE_IMAGE_CACHE_FORCE_DYNAMIC_URL` is set to `true`, the static \"physical\" URL is never exposed, only the dynamic URL. This can be helpful when page caches keep serving the static URL even though it does not exist for some reason. The dynamic URL always works. Drawback is that serving with the dynamic URL is a bit slower, so only use it if you encounter caching issues.\n- update mediaelement dependency in Podlove Web Player 2\n- update Podlove Web Player 4\n\n= 2.6.2 =\n\n- send `HTTP 410 Gone` when accessing a download URL to a depublished episode\n- fix Podlove Web Player 4 appearing on wrong positions when multiple players are embedded on the same page\n\n= 2.6.1 =\n\n- fix template bug (when a template returned an empty result, the template title was displayed instead)\n\n**Flattr**\n\n- rename \"Flattr Username\" to \"Flattr ID\"\n- insert `flattr:id` meta tag to page heads, which is required for their new system\n\n= 2.6.0 =\n\n**New Module: E-Mail Notifications**\n\nThis is a new module complementing the existing contributor module. Once activated, it enables you to send emails to contributors when an episode gets published.\n\nYou can find the settings at `Contributors > E-Mail Notifications`. The settings are:\n\n- _email subject and message_, customizable with all available template tags\n- a _time delay_ until the message is sent after the episode was published\n- who will receive the message by using _groups and roles as a filter_\n\n**Podlove Web Player 4 Alpha**\n\nAfter months of work we're ready to show you what we're working on: A new take on the modern Web Player. We learned from previous attempts and thought about responsiveness and embeddability from day one. Of course there's full support for chapter marks, too.\n\nGive it a try if you like. Be aware though that it's marked as _alpha_, meaning we're still working on new features and fixing bugs in existing ones when we find them. But if you're curious, head over to `Podlove > Podcast Settings > Player`, switch to Podlove Web Player 4 and [let us know what you think](https://community.podlove.org/c/podlove-web-player).\n\nOnce Podlove Web Player 4 is stable, it will be the only actively supported Podlove Player. Podlove Web Player 3 isn't being developed anymore.\n\nAnd finally, all web players can now be previewed on the settings screen.\n\n**Templates**\n\n* Assets now have an identifier `asset.id` to quickly access a file for an asset within an episode, for example: `episode.file(\"mp3\")`\n* Contributors already had such an id, but now there is a new accessor to get a single contributor by id, for example: `podcast.contributor(\"jerry\")`\n* DEPRECATED: id parameter in `podcast.contributors` to access a single contributor: `podcast.contributors({id: \"jerry\"})`.\n* Feeds can now be accessed the same way by their slug: `podcast.feed(\"mp3\")`\n\n**Analytics**\n\nDownload tracking is now turned on by default in new setups.\n\n**Bits & Pieces**\n\n* network dashboard statistics: fix average length and file size; remove \"days between episodes\"\n* episodes: prettify \"detect episode duration\" UI\n* episodes: enhance media file UI:\n  * rename \"update\" button to \"verify\"\n  * move \"update all media files\" button above \"verify\" buttons and rename it to \"verify all\"\n  * show file URL even if url cannot be reached\n  * show Bytes in human readable format\n* episodes: fix focus when adding contributors to episodes\n* episode asset settings: increase assets per page from 10 to 100\n* episode asset settings: fix format filtering by type\n* episode assets: new \"name\" attribute for reference in templates, for example `episode.file('cover').url` where \"cover\" is the asset name\n* support page: add Publisher icon to \"Get Professional Support\" callout\n* contributors: services only get deleted on \"Save Changes\", not immediately\n* contributors: fix sorting by contribution count in the admin interface\n* contributors: fix minor PHP issue when creating new contributors\n* templates: immediately purge cache on updating any template\n* Podlove Subscribe Button: add module option to deliver locally instead of using the CDN, but continue to default to CDN\n* job dashboard: add mode description to jobs where necessary to distinguish between different run settings\n* be tolerant to missing PHP iconv module\n* fix Auphonic chapter import\n* update Hindenburg chapter parser\n* new PHP constant `PODLOVE_DISABLE_IMAGE_CACHE` to disable image caching\n* fix security vulnerability (thanks to DefenseCode, who found the vulnerability using their tool ThunderScan and kindly approached us)\n\n= 2.5.3 =\n\n* fix broken settings tabs (introduced by German translation)\n\n= 2.5.2 =\n\n* fix episode sorting by recording date (`podcast.episodes({orderby: 'recordingDate'})`)\n* services: use https URLs where available ([#911](https://github.com/podlove/podlove-publisher/pull/911))\n* services: add Spreaker ([#912](https://github.com/podlove/podlove-publisher/pull/912))\n* contributors: adjust description of \"Public Name\" because it was misleading ([#914](https://github.com/podlove/podlove-publisher/pull/914))\n* services: fix URL encoding issue ([#915](https://github.com/podlove/podlove-publisher/pull/915))\n* network: fix system report notice that ensures module is setup correctly ([#916](https://github.com/podlove/podlove-publisher/pull/916))\n* remove ADN link from support page\n\n= 2.5.1 =\n\n**Enhance Chapter UI**\n\n* fix encoding of \"&\" character\n* add support for Hindenburg project files\n\n= 2.5.0 =\n\n**New Chapter Management UI**\n\nUntil now if you wanted to add chapters to your podcast, you had to write the mp4chaps format by hand into a textfield. The Publisher now finally provides an easy-to-use interface to manage chapters that doesn't require any knowledge about the underlying formats.\n\nThe new inferace makes it simple to import chapters from files. We currently support [PSC (Podlove Simple Chapters)](https://podlove.org/simple-chapters/), mp4chaps and Audacity Track Labels. [Let us know](https://community.podlove.org/c/podlove-publisher) if we don't support your favorite program's export format.\n\n**Module: Import/Export**\n\nPodcast import has been rewritten to make full use of Background Jobs. That way podcasts of any size can be imported without running into system resource restrictions for large podcasts.\n\n**Background Jobs**\n\nThe jobs dashboard on the tools page now shows job statuses in realtime (refreshes automatically).\n\nAdjusted background job duration parameters and made them configurable. The change of defaults aims to make better use of available cron time (normally 30 seconds per request), which can speed up long running background jobs dramatically.\n\nPlease refer to the new [Background Jobs page in the documentation](http://docs.podlove.org/podlove-publisher/developer/background-jobs.html) for more details.\n\n**Bits & Pieces**\n\n* remove module: App.net (because they [are shutting down](http://blog.app.net/2017/01/12/app-net-is-shutting-down/))\n* fix tracking export\n* redirects (expert settings): redirect counter can be reset\n* contributor avatars use WordPress media picker\n* optimize use of JavaScript:\n    - only load scripts on pages that require them\n    - concatenate and minify some scripts\n\n= 2.4.6 / 2.4.7 =\n\n- Image cache: support non-pretty permalinks.\n- fix feed compatibility issue with [Relevanssi plugin](https://wordpress.org/plugins/relevanssi/)\n- services: fix lastfm url scheme (thanks [jazzcrack](https://github.com/jazzcrack))\n\n= 2.4.5 =\n\nImage cache: change URL encoding method to fix Gravatar issues.\n\n= 2.4.4 =\n\nFurther image cache improvements:\n\n- reject URLs that are not images\n- prefix query vars to avoid naming conflicts with other plugins\n- fix resizing sometimes not calculating the correct dimensions\n- enhancement: skip http when using images shipped with the Publisher; copy images from Publisher to cache directory on filesystem instead\n\n= 2.4.3 =\n\nFix issue with broken images introduced in 2.4.2.\n\n= 2.4.2 =\n\n**Improve Image Caches**\n\nImages relevant to the Podlove Publisher are downloaded so they can be resized to desired dimensions. This makes it possible to deliver retina-images and improves the website performance because only the appropriate image size delivered.\n\nThis requires files to be downloaded. Until now, this happened in the background to avoid slow web page load times when the image is fetched. Until the cache existed, the original file URL to the unresized file was used. This worked alright, unless you were using a page cache plugin. The original file URL would be used much longer than necessary, causing big file downloads.\n\nNow a different approach is used. Instead of the original file URL, a dynamic link is generated, looking like this one: `https://example.com/podlove/image/http%3A%2F%2Fexample.com%2Fmedia%2Fmypodcast%2Fmy-podcast-logo-1500x1500.jpg/300/300/0/my-podcast`. When this link is requested, the cached and resized image is either delivered or, if it doesn't exist, generated on-the-fly. Once the cached file exists, the direct link to the cached file is delivered, just like before. The major improvement is that even if the initial URL is stuck in your page cache, the Publisher is now able to deliver a properly resized image anyway.\n\n**Other**\n\n* set correct feed Content-Type in HEAD requests and redirects\n* enhancement: repair & clear cache tools print a notice about other cache plugins\n* fix \"Last Month\" download widget in analytics\n\n= 2.4.1 =\n\n* services: Playstation Network Account now links to `http://psnprofiles.com`\n* allow spaces in episode slugs\n* add help tab to analytics pages\n* fix Podlove Web Player 2 timecode share link\n* fix issue with image cache filenames ([PR 895](https://github.com/podlove/podlove-publisher/pull/895))\n* fix WP-Rocket incompatibility with Podigee Player\n* fix Auphonic: when neither episode image nor post thumbnail are present, fallback to podcast cover art\n* fix PHP warnings in oembed module\n\n= 2.4.0 =\n\n**Background Jobs System**\n\nCrunching numbers for Analytics takes time, especially for popular podcasts with many downloads. The old system was written optimistically and \"let's-hope-we-finish-before-we-run-out-of-time\"-ish. That was certainly good enough for podcasts with a few hundred downloads per episode, but more likely a gamble for popular shows.\nTo solve this issue in a scaleable way, we built what is known as \"background processing\" or \"queues\". That way we can break big tasks into small chunks and process them step by step. You don't really need to know about this, since the main effect is that calculating analytics should be a smoother experience (if you have ever had troubles in that regard) but if you are curious, have a look at the new \"Tools\" section, which lists running and recently finished jobs.\n\n**New Analytics Dashboard**\n\n* \"Downloads per Day\" is a stacked bar chart now so you can see which episode is responsible for peaks\n* Downloads table now shows downloads in time segments starting from the moment of episode release for better comparability\n* Show total number of all downloads in Analytics Dashboard\n* New information under downloads table: age of the data and when the next refresh is due\n* _MUCH_ better performance / page load times\n* New option to configure how many episodes per page should be shown\n\n**Podlove Web Player 2 Facelift**\n\n* simplified, modernised look\n* responsive layout for mobile devices\n* fix: updated mediaelement library to fix volume bar display bug\n\n**Podigee Podcast Player**\n\nThe [Podigee Podcast Player](https://www.podigee.com/en/podcast-player) is available as an alternative to the Podlove Web Players. It supports everything you can expect from a modern web player like chaptermarks. It is also embeddable.\n\n**New \"Tools\" Menu**\n\n* A new maintenance section gathers tools like the \"Repair\" button in one place\n* There is a new section for analytics related maintenance\n* Import & Export is now in the \"Tools\" menu\n\n**Improved Logging Display**\n\n* Logging is now in the \"Support\" menu\n* add filtering for different severities\n* hide \"info\" entries by default\n* improve readability of data sections\n\n**Other**\n\n* add Emoji support for episode subtitle, summary and chapter marks (requires WordPress 4.2 or newer)\n* Web Player setttings moved from Expert Settings to Podcast Settings\n* When activating the plugin, add mp3 asset and feed to help users get over the most confusing part of the setup.\n* Post thumbnails can be used as episode covers (see settings in \"Episode Assets\"). This is the new default.\n* add contributors shortcode to default template (Many people activated contributors and then wondered why they were not displayed in the episode. Now the shortcode is part of the default template, but only if the contributors module is active.)\n* add unmistakable warning if curl is not available and provide actionable steps for a solution\n* change feed setting \"Include HTML Content\" default to \"on\"\n* remove log entries for beginning and ending asset validations\n* move feed protection into separate module\n* move debug log from Dashboard to Support page\n* add ptm_request parameter to redirected tracking URLs which contains a uuid\n* add fyyd.de module\n* add option to enforce http or https in URLs inside feeds (enclosures, images) and the actual feed URLs; see `Expert Settings > Website`\n* improve tracking: ignore 1-byte requests\n* update user agent library (new/updated clients: Podcat, Downcast, iCatcher, BashPodder)\n* show total downloads per site in network dashboard\n* remove `<itunes:keywords>` from feed (it disappeared from the specification)\n* remove module: \"Feed Validator\"\n* update recommended image size to 3000x3000 pixel\n* add heartbeat to keep note of when tracking is active\n* `shortcode_exists($shortcode_name)` is now available in Twig templates\n* system report: add notice if ALTERNATE_WP_CRON is active\n* fix tracking export: keep httprange\n* fix compatibility with other plugins relying on Spyc library\n* fix `{{ episode.duration.totalMilliseconds }}`\n* fix image caching issue (invisible characters)\n* fix: When plugin requirements are not met, admin notices are now still shown once but the plugin is automatically deactivated after that. This avoids faulty setups.\n* fix: show podcast covers in network site switcher\n* fix: expert settings not saving on some systems\n\n= 2.3.18 =\n\n* fix Auphonic authentication (https certificate issue)\n\n= 2.3.17 =\n\n* fixes a bug that broke some settings pages\n\n= 2.3.16 =\n\n* security: fix SQL injection\n* security: remove several Cross-Site Scripting vulnerabilities\n\nAll vulnerabilities require admin capabilities. That means they cannot be exploitet easily, but could be using Cross-site request forgery (CSRF).\n\nThanks to [RIPS Technologies](https://www.ripstech.com/) for reporting these issues. The issues were found using their Static Source-Code Analyzer RIPS.\n\n= 2.3.15 =\n\n* ensure 3rd party PHP dependencies do not require PHP 5.5 or greater\n\n= 2.3.14 =\n\n* fix: send episode cover to Auphonic if available\n* fix: improved download logic for `geoip.mmdb` should prevent faulty downloads\n* enhancement: error message for faulty `geoip.mmdb` includes instructions on how to manually fix the file\n* enhancement: automatically switch off geo tracking when no valid geo database is available\n* enhancement: clarify episode image asset options\n\n= 2.3.13 =\n\n* fix: sort contributor names while ignoring uppercase/lowercase\n* fix: when exporting a podcast, don't call `htmlspecialchars` on arrays because it breaks things\n* fix: image caching issue (invisible characters)\n* fix: broken geolocation database does not prevent playing episodes\n* fix `{{ episode.duration.totalMilliseconds }}`\n* fix: `{{ episode.duration }}` returns \"00:00\" if no duration is set\n* fix: contributor avatar URLs with umlauts\n* enhancement: check for geolocation database validity in tracking debug section\n* enhancement: add current theme and feed URLs to system report\n* Podlove Subscribe Buttons: parameters in templates and shortcodes can override Publisher provided fields: 'title', 'subtitle', 'description', 'cover'\n\n= 2.3.12 =\n\nDesign Update for Podlove Subscribe Button\n\n* The button now follows a flat design and has more options for customizability.\n* See [docs.podlove.org/podlove-subscribe-button](http://docs.podlove.org/podlove-subscribe-button/) for a range of possible display variants.\n* Widget module has been updates to support a color picker and settings for size, format and style. When using the \"WordPress Customizer\" you get a live preview of the button.\n* If you are using the Template API, have a look at the updated [`podcast.subscribeButton` parameters](http://docs.podlove.org/podlove-publisher/reference/template-tags.html#podcast).\n\n= 2.3.11 =\n\n* fix feed issue that appeared with WordPress 4.5 (wrong content type)\n\n= 2.3.10 =\n* when activating the plugin, add mp3 asset and feed to help users get over the most confusing part of the setup\n* fix tracking export: keep httprange\n* fix compatibility with other plugins relying on Spyc library\n* improve tracking: ignore 1-byte requests\n* update user agent library (new/updated clients: Podcat, Downcast, iCatcher, BashPodder)\n* remove `<itunes:keywords>` from feed (it disappeared from the specification)\n* update recommended image size to 3000x3000 pixel\n\n* fix Podlove Subscribe Button iTunes link\n* add new \"getting started\" video to readme\n\n= 2.3.9 =\n\n* fix `open_basedir` related issues\n\n= 2.3.8 =\n\n**Bugfixes**\n\n* fix '&' issue in some fields when exporting/importing\n* player: pass podcast language code to web player v3\n* open graph: do not include non-downloadable assets\n* open graph: use tracking URLs if available\n* template editor: add scrolling when having many templates in the list\n* auphonic: disable \"Open Production\" button when no production is selected\n* player: chapters visibility setting now applies to v3 beta player\n* more defensive feed gzipping for compatibility with various caching plugins\n* fix feed discovery cache (can now handle both http and https at the same time)\n\n**Enhancements**\n\n* enhance error message when resolving URL fails\n\n\n= 2.3.7 =\n\n* fix \"add new\" contributor button\n* fix migration class error\n* fix migration system report display\n* use default WordPress background color in migration wizard\n\n= 2.3.6 =\n\n**Bugfixes**\n\n* When creating a new contributor, social and donation services are saved correctly\n* Deleting a contributor shows the correct confirmation message\n\n**Enhancements**\n\n* Podlove Subscribe Button: When an iTunes id is known for a feed, the button does not just pass the feed URL to the client when iTunes or Podcasts App are chosen. It redirects the user to the iTunes directory first. Because if you don't do this, \"[it] does not increase your visibility on the iTunes Store or allow you to earn commission as part of the Affiliate Program.\" (http://www.apple.com/itunes/podcasts/specs.html)\n* Detect and warn if an episode slug has been used before\n\n= 2.3.5 =\n\nUpdate Web Player v3\n\n* beta.6 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-beta.6\n* rc.1 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc.1\n* rc.2 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc2\n* rc.3 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc3\n\n= 2.3.4 =\n\n**Web Player (v3 Beta)**\n\nThe new web player can be selected in `Expert Settings > Web Player`. Please try it out and send us your feedback. Thanks!\n\n* update player\n* add theme player options\n* fix player permalink parameter\n* fix player width on Mobile Safari\n\n**Other**\n\n* fix Auphonic workflow bug: when finishing a production, media files would sometimes erroneously be detected as not existing\n* detect when the configured Auphonic Preset does not exist\n* fix focus when adding new related episode rows\n* chosen search fields allow partial searches\n* enable Twig date extension to allow `time_diff` filter; see [Date Extension Documentation](http://twig.sensiolabs.org/doc/extensions/date.html)\n\n= 2.3.3 =\n\nUpdating all the things for your pleasure.\n\n= 2.3.2 =\n\n* add template accessor `episode.post` to get WordPress post object\n* fix: template call `episode.chapters` returns an empty list when there are no chapters\n* fix: deleting image cache when no image cache directory exists\n* fix: cache purge also deletes timeout entries\n* fix: cache purge affects downloads table\n* fix: JavaScript event for secondary download button\n* fix: default template assignment on plugin activation\n* fix: unpublished relates episodes do not appear when using the shortcode or template accessor\n\n= 2.3.1 =\n\n* simplify download buttons (`[podlove-episode-downloads style=\"buttons\"]`) style to better adapt to themes\n* fix: missing \"Show URL\" download button in twentyfifteen theme\n* fix: URL structure for YouTube channels\n* fix: player visibility when JavaScript is disabled\n* fix: stop loading nonexisting player assets in WordPress admin area\n* enhanced system report: change wording for `open_basedir` issue to clarify that it _should_ be fixed but a workaround exists\n* enhanced plugin loading\n  * When upgrading from version 1.x to 2.x using PHP 5.3, the upgrade lead to the \"White Screen of Death\" because 2.x requires PHP 5.4. This case is now handled and the Publisher shows an appropriate admin notice.\n  * Some shared hosters seem to have problems with the plugin update process, which leads to the Publisher missing files and breaking the site. This is now also detected and a notice appears, asking the user to manually redownload the plugin.\n\n= 2.3.0 =\n\n**New Module: Seasons**\n\nDo you have seasonal content? We got you covered. The new \"Seasons\" module allows you to group episodes into seasons. Each season has a title and other optional metadata, like a custom image. You can access all this data using the template system.\n\nNew Template accessors:\n\n- `episode.season` returns the season for the episode\n- `podcast.seasons` returns a list of all seasons\n- `season.episodes` returns a list of all episodes in a season\n\n**New Module: Flattr**\n\nEverything Flattr related was moved into its own module.\nIf you don't use Flattr, you can turn it off and it gets out of your way.\n\n* If you are using the Flattr module, we write Flattr payment information into podcast feeds. This way you don't need to rely on the official Flattr plugin to do this. You can probably deactivate it if you were using it since we provide the main functionality within the Publisher now.\n* We recently changed the default `flattr` parameter in shortcodes. Now there's a setting in Flattr Podcast Settings where you can define the default parameter for contributor shortcodes.\n\n**New Module: Related Episodes**\n\nYou can now express that episodes are related to each other. You can list all related episodes using the new shortcode `[podlove-related-episodes]` or using the template accessor `episode.relatedEpisodes`.\n\n**Templates & Themes**\n\nIf you are developing themes, you now have full access to the Publisher Template system. The API is exactly the same as in Twig, just the syntax is different. At the moment, there are 4 entry points:\n\n- `\\Podlove\\get_episode()`\n- `\\Podlove\\get_podcast()`\n- `\\Podlove\\get_flattr()`\n- `\\Podlove\\get_network()`\n\nPlease see the [\"Understanding Templates\" guide](http://docs.podlove.org/guides/understanding-templates/) for more details.\n\n**Other**\n\n* Use WordPress Object Cache API to cache model objects. All entities fetched by id are cached and reused within the same page call. Performance gains are most notably in complex templates, which often access the same data repeatedly.\n* Analytics: Update & improve user agent detection library so you can have more accurate analytics.\n* Canonical feed URLs. WordPress respects if you want your URLs to end with a slash or not (you do that by adding or removing the trailing slash from your WordPress permalink settings custom structure). Our feed URLs now respect this choice, too. Furthermore, we permanently redirect to the canonical URL if another one was accessed to ensure all clients access _exactly_ the same feed URL.\n* News from podlove.org are displayed in the Podlove Dashboard\n* Users with role \"author\" and higher now have access to the Podlove Dashboard and Analytics. They only have access to dashboard sections that make sense for authors, so they won't see logging, feed or asset validation.\n* Contributors can now be edited in _Contributor Settings_ (instead of _Episodes > Contributors_)\n* Contributors Social Services: It is now possible to add a YouTube \"Channel\", not just user profiles\n* Contributors Social Donations: Add \"paypal.me\" option\n* Add functionality to automatically determine the duration for episodes. This is especially useful for people who don't use Auphonic, which already determines the duration automatically.\n* We are now able to handle media files that are served without a \"Content-Length\" header. A specific warning is generated and the size is displayed as \"unknown\", but the files are treated as valid so they can be played.\n* Add support for Auphonic webhooks. This allows us to import your episode metadata once an Auphonic production is finished — even if you navigated away from the episode page.\n* Podcast cover image can now be uploaded using the WordPress media uploader.\n* Add `contributor.gender` template accessor\n* Rename network list \"description\" to \"summary\" for consistency. In templates `list.description` is now deprecated. Please use `list.summary` instead.\n* fix: Shortcodes in episode subtitle and summary are not interpreted any more. Both fields were always considered plain text and having shortcodes leads to various issues, especially in feeds.\n* export files are now gzipped if possible\n* fix JavaScript incompatibility related to Diaspora plugin ([#771](https://github.com/podlove/podlove-publisher/pull/771), [#770](https://github.com/podlove/podlove-publisher/pull/770), [#425](https://github.com/podlove/podlove-publisher/issues/425), thanks [@noplanman](https://github.com/noplanman)!)\n* fix: failing geo-lookup does not break tracking links\n* fix: Remove WordPress favicon (since WP 4.3) from podcast feeds if a podcast image is set\n* fix: pasting into a template creates change-marker\n* fix: tracking import does not skip the last few entries\n\n= 2.2.4 =\n\n* fix: erratically missing chapter information in RSS feeds\n* fix: \"Allow to skip feed redirects\" setting was sometimes ignored\n\n= 2.2.3 =\n\n* fix: web player image fallback to podcast image when an episode image asset is defined but unused\n* fix: gzip compression: only set content type if headers have not been sent\n* fix: in networks, don't schedule template cleanups for blogs without an active Publisher\n\n= 2.2.2 =\n\n* fix: template cache issue where duplicate purge cronjobs could flood the cron system\n* fix: image cache validation (didn't work due to missing library)\n\n= 2.2.1 =\n\n* fix: App.net announcement preview in modules\n* fix: asset validations are always scheduled properly\n* fix: Remove method calls that require WordPress 4.0+ (wpdb::esc_like)\n\n= 2.2.0 =\n\n**Image Caching, Resizing & Retina Support**\n\nWe now take better control of podcast images, episode images, contributor avatars and our own social icons.\nWe are able to *resize* them to ideal sizes, which results in *faster page load times* for your users. *Retina\nimages* for higher-resolution displays are also supported. We do this automatically, so all you need to do\nis click update, lean back and enjoy.\n\nRead all the details in our blog post [\"Podlove Publisher 2.2: Say hello to image caching\"](http://podlove.org/2015/05/20/podlove-publisher-2-2/)\n\nThis update increases the WordPress requirement from 3.0 to 3.5 (due to the required image editing functionality).\n\n**Other**\n\n* fix: duplicate feed discovery\n* fix: ignore incomplete feed configurations\n* fix: don't include network admin module css in frontend\n* fix: dashboard episode edit links\n* fix: when deleting WordPress Network sites, trigger plugin uninstall to remove database tables\n* fix: web player flash fallback\n* fix: network templates now also appear in the template widget and template auto-insert setting\n* fix: issue where some database tables were not created\n* fix: podcast covers are displayed in frontend admin menu bar\n* show Twig template errors in dashboard log\n* web player template tag can set tracking context: `episode.player({context: 'landing-page'})`\n* add `episode.categories` template tag\n\n**Deprecations**\n\n- deprecated `episode.imageUrl`, use `episode.image` instead\n- deprecated `episode.imageUrlWithFallback`, use `episode.image({fallback: true})` instead\n- deprecated `podcast.imageUrl`, use `podcast.image` instead\n- deprecated `service.logoUrl`, use `service.image` instead\n- deprecated `contributor.avatar`, use `contributor.image` instead\n\nWhile you are changing these, consider scaling them down appropriately. Your images are probably huge but in many cases you don't need the full size. So instead of `episode.image` or `episode.image.url`, specify a size, like this `episode.image.url({width: 200})`.\n\n= 2.1.3 =\n\n* add warning in system report for users with default permalink settings (which is problematic for some podcast clients)\n* enhancement: delete caches in all blogs when changing a network template\n* enhancement: delete caches when changing the template default assignment\n* enhancement: do not rely on openssl module\n* fix: add flattr setting to contributors general tab\n* fix: duplicate episodes when using `podlove.episodes` template accessor\n* fix: correctly fire plugin activation hooks in network mode\n* fix: ensure network module is activated correctly\n* fix: \"Add New\" link in empty list tables\n\n= 2.1.2 =\n\n* fix issue with users that have open_basedir set, which lead to all assets being invalid\n\n= 2.1.1 =\n\n* fix: remove obsolete \"Add New\" template button from network templates screen\n* fix: template autoinsert does not use deprecated \"id\" parameter\n* fix: template widget does not use deprecated \"id\" parameter\n* fix: duplicate episodes in feeds\n* fix: some server configurations (especially on shared webhosting) break cURLs ability to follow HTTP redirects. We now check for that configuration and, if necessary, resolve the URL manually before continuing normally.\n* fix: XSS vulnerabilities in contributors search\n* fix: Template accessor `contributor.id` now correctly returns the id, not the uri. `contributor.uri` is the new accessor to get the uri.\n* fix: Filtering contributions by id is now correctly affected by other filters, like group and role. Until now, `podcast.contributors({id: 'james', role: 'on-air'})` always returned James, no matter if he had the given role or not.\n* add \"Add New Contributor\" item to contributor select list. Selecting it opens the screen to add a new contributor.\n* add Twig version to system report\n\n= 2.1.0 =\n\n**Networks: WordPress Multisite Support is Here**\n\n- dedicated WordPress Multisite support\n- \"My Sites\" menu features podcast covers and menus include often used pages like \"Podlove Dashboard\" and episodes\n- Network Dashboard provides a birds-eye view over your podcast empire\n- Network-Templates that are accessible in every podcast\n- Podcast lists: give templates access to multiple podcasts at once, allowing you to automatically list all podcasts in your network, the 10 last episode releases in your network and much more\n\n**Widgets**\n\nWe added a happy bunch of widgets to make your life easy.\n\n* Podcast Information: Display cover, subtitle and summary of your podcast\n* Recent Episodes: Display a list of recent episodes, with cover art and duration if you like\n* Template: Display any Publisher template in a widget area\n* Podcast License\n\nThe Subscribe Button Widget now defaults to \"Big with Logo\" and auto-width. It has also been renamed to \"Podcast Subscribe Button\" to be distinguishable from the new standalone plugin.\n\n**Templates**\n\n* add accessors `{{ podcast.landingPageUrl }}`, `{{ podcast.subscribe_button }}` (see http://docs.podlove.org/reference/template-tags/#podcast)\n* add accessor `{{ flattr.button }}` (see http://docs.podlove.org/reference/template-tags/#flattr)\n* add accessor `{{ episode.podcast }}`\n* add query parameters to ``{{ contributor.episodes }}`:\n\n    - group: Filter by contribution group. Default: ''.\n    - role: Filter by contribution role. Default: ''.\n    - post_status: Publication status of the post. Defaults to 'publish'\n    - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n      - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n      - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n    - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'.\n      - 'publicationDate' - Order by publication date.\n      - 'recordingDate' - Order by recording date.\n      - 'title' - Order by title.\n      - 'slug' - Order by episode slug.\n      - 'limit' - Limit the number of returned episodes.\n\n**Other**\n\n* add gender contribution statistics to dashboard\n* add expert setting \"Allow to skip feed redirects\"\n* add warning in tracking settings when default permalink structure is used\n* add support for Auphonic cover art\n* add support for Jetpack \"Publicize\" module to podcast post type\n* add warning when open_basedir is set to system report\n* add daily cleanup of logging table (only keep entries of previous 4 weeks)\n* contributor editing has a tabbed interface\n* improved Podlove Dashboard performance\n* Open Graph title does not include episode subtitle any more. If a subtitle is available, it is put in front of the summary in the description tag.\n* fix: remove Jetpack \"Site Icon\" from podcast feeds\n* fix: empty template editor when last template is deleted\n* fix: empty caches when a scheduled episode gets published\n* fix analytics episode average calculation for ancient episodes\n\n**API changes**\n\n* Flattr parameter in `[podlove-episode-contributor-list]` now defaults to \"no\". If you need to reactivate it, use `[podlove-episode-contributor-list flattr=\"yes\"]`\n* `[podlove-web-player]` was renamed to `[podlove-episode-web-player]` to avoid clashes with the standalone web player plugin. For now, the old shortcode still works.\n* `[podlove-subscribe-button]` was renamed to `[podlove-podcast-subscribe-button]` to avoid clashes with the standalone button plugin. For now, the old shortcode still works.\n\n= 2.0.5 =\n\n* fix: template editor cursor position in Safari (by changing to a different theme that doesn't use bold styles)\n* fix: double escaped feed enclosure URLs when using non-pretty-permalinks\n\n= 2.0.4 =\n\n* fix: missing flattr attribute for contributors\n* fix: subscribe button description is properly wrapped in p-tags\n* fix: faulty valid file if check returns \"unreachable\" but includes a Content-Length header\n* fix: more thoughtful handling of ETags when validating files prevents failing updates\n* fix: \"NaN\" analytics should display properly now\n* fix: off-by-one display in analytics\n* fix: don't HTML-encode quotes in episode title/subtitle/summary since it leads to invalid feeds\n* add trakt.tv to the services list\n* add support for RSS channel image tag\n\n= 2.0.3 =\n\n*Allow Non-Admins to access Analytics*\n\nAnalytics have a new capability called \"podlove_read_analytics\".\nYou can provide access to, for example, editors, using the following code snippet:\n\n    function podsnip_add_capability() {\n        // default roles: editor, author, contributor, subscriber\n        $role = get_role('editor');\n        $role->add_cap('podlove_read_analytics');\n    }\n    add_action( 'admin_init', 'podsnip_add_capability');\n\nYou can add snippets using the \"Code Snippets\" plugin.\n\n*Bugfixes*\n\n* fix: use proper HTTP method to create/update/delete templates\n* fix: don't remove URLs from chapter marks when saving\n* fix: optional episode form elements can be saved\n\n= 2.0.2 =\n\n* fix: include missing YAML library\n* fix: namespacing issue in uninstall procedure\n* fix: debug tracking example file must be downloadable\n\n= 2.0.1 =\n\n**Bugfixes**\n\n* fix: properly sanitize episode form data (fixes \"A wild Backslash appears\")\n\n**Enhancements**\n\n* format download numbers in episode list\n* remove check for PHP setting `allow_url_fopen` because we don't rely on it any more\n\n= 2.0.0 =\n\n**Download Analytics**\n\nYou want to know more about who listens to your podcast? We got you covered.\n\nWe spent months of research and prototyping to find a reliable way of tracking. We are confident that our approach works and produces trustworthy data. If you have not done so yet, you have to activate tracking in _Expert Settings -> Tracking_.\n\nIf you are interested in all the technical details, head over to http://docs.podlove.org/guides/download-analytics/.\n\nBut what you are seeing now is just the beginning. We have a plethora of ideas on how to give you even more insight into the data available. Stay tuned!\n\nWe are curious what you think about the current analytics interface? What do you love? What do you hate? What do you miss? Head over to our new community site and share your thoughts: https://community.podlove.org/\n\n**Bugfixes**\n\n* fix: use `home_url()` instead of `site_url()` to generate tracking URLs\n* fix: tracking export does not get stuck forever when it fails once\n* fix: disappearing podcast description settings\n* fix: add function to repair button that removes duplicate episode entries\n* fix: template editor does not forget changes if you reselect a template after changing it\n* fix: improve uninstall routine\n* fix: wrong month when choosing Auphonic productions\n* fix: deactivate Jetpack's OpenGraph when the Publisher OpenGraph module is active\n\n**Other Changes**\n\n* add services: miiverse, prezi\n* add missing services via repair button\n* Bitlove: add `<bitlove:guid>` to RSS feed and use this to identify files\n* moved episode GUID regeneration into separate metabox because it's rarely required\n* always check media files when opening an episode edit page\n* move podcast cover art from media tab to description tab\n\n* Improved feed settings\n  * check for missing and duplicate slugs\n  * check for missing asset assignment\n  * show prominent warning for detected problems\n  * provide contextual help to better understand what's required and why\n\n**Removed Functionality**\n\n* removed module \"Auphonic Production Data\"\n* removed the following shortcodes (use [Template Tags](http://docs.podlove.org/reference/template-tags/) instead)\n  * `[podlove-episode-subtitle]`\n  * `[podlove-episode-summary]`\n  * `[podlove-episode-slug]`\n  * `[podlove-episode-duration]`\n  * `[podlove-episode-chapters]`\n  * `[podlove-episode field=\"...\"]`\n  * `[podlove-podcast field=\"...\"]`\n  * `[podlove-show field=\"...\"]`\n  * `[podlove-podcast-license]`\n  * `[podlove-episode-license]`\n  * `[podlove-contributors]` (use `[podlove-episode-contributor-list]` instead)\n  * `[podlove-contributor-list]` (use `[podlove-episode-contributor-list]` instead)\n* removed the following template tags\n  * `{{ contributor.publicemail }}` (use social module instead)\n  * `{{ license.html }}` (use `{% include '@core/license.twig' %}` instead)\n\n= 1.12.1 =\n\n* fix: catch failed IP categorizations\n* fix: solve PHP notice\n* add custom icon to close template fullscreen mode\n* add custom contributor css to look nicely in twentyfifteen theme\n\n= 1.12 =\n\n- enable some WordPress template tags in Twig Templates: `is_archive()`, `is_post_type_archive()`, `is_attachment()`, `is_tax()`, `is_date()`, `is_day()`, `is_feed()`, `is_comment_feed()`, `is_front_page()`, `is_home()`, `is_month()`, `is_page()`, `is_paged()`, `is_preview()`, `is_search()`, `is_single()`, `is_singular()`, `is_time()`, `is_year()`, `is_404()`, `is_main_query()`\n- enable episode filtering by category slug: `podcast.episodes({category: \"kitten\"})`\n- redesigned template editor interface\n- fix feed cache issue which lead to enclosure URL mixups\n- display PHP deprecation warning aggressively for everyone below 5.4\n\n= 1.11.2 =\n\n- Cache feed items. This drastically reduces load when no feed proxy is used; especially in a \"full feed\" with many episodes.\n- Add Luxembourgish to languages\n\n= 1.11.1 =\n\nSubscribe Button fixes & enhancements:\n\n- don't pass undiscoverable feeds to the button\n- don't show a button if no feed is available\n- change defaults to \"big-logo\" and \"autowidth\"\n- fix issue with internal format\n\n= 1.11 =\n\nSay hello to the **Podlove Subscribe button**, the *Universal button to subscribe to buttons in the desired podcast client or player website*. It ships as a widget, so you can easily display it on your site. For more finegrained positioning, you can use the `[podlove-subscribe-button]` shortcode.\n\nMore info on those sites:\n\n* Homepage: http://podlove.org/podlove-subscribe-button/\n* Help Translate: http://translate.podlove.org\n* GitHub: https://github.com/podlove/podlove-subscribe-button\n\n**Other Changes**\n\n* fix `contributor.episodes`: only show published episodes\n* fix redirect form: remove url validation\n* fix HEAD requests for download URLs\n* redirects are counted and displayed in the redirect settings\n\n= 1.10.23 =\n\n**Bugfixes**\n\n* fix social repair module\n* empty rss feeds now render properly\n* fix issue of randomly breaking URLs\n* fix missing files when using auto-publish feature by automatically validating files before publishing\n* fix \"open\" link for last contributor donations item\n* fix javascript error in license ui\n\n**New Features**\n\n* add basic client-side input validation to avoid typing errors: Leading and trailing whitespace will be removed automatically. URL and email fields are automatically syntax checked.\n* add support for scientific networks: ResearchGate, ORCiD, Scopus\n* add explicit support for \"Duplicate Post\" plugin: duplicated episodes now regenerate GUIDs and contributions are copied, too\n\n**Enhancements & Others**\n\n* contributors form:\n  * switch public name and real name fields\n  * remove public email field (see deprecations)\n  * move contact email field to general section\n* ADN module: add option to not fall back on episode cover when no episode image is present\n* adjust Bitlove script so it plays well with https sites\n* include date in tracking export filename\n* move web player settings to expert settings\n* public contributor emails are handled by the social module now, instead of being a contributor attribute\n\n**Deprecations & Migration**\n\nIf you are using `{{ contributor.publicemail }}` in your templates, you should change it to something like the following:\n\n    {% for service in contributor.services({type: \"email\"}) %}\n        <a target=\"_blank\" href=\"{{ service.profileUrl }}\">{{ service.rawValue }}</a>\n    {% endfor %}\n\n= 1.10.22 =\n\n* fix bug in contribution counting\n* simplify internal cache key handling to avoid technical issues\n* support more licenses (CC4.0, CC0, Public Domain)\n* tracking: don't count HEAD requests\n* tracking: add manual migration notice to delete accidentally recorded HEAD requests\n\n= 1.10.21 =\n\n* improve HHVM compatibility\n* resolve bug concerning internal article linking\n* use WordPress method to generate default episode slugs for better results (if you are using a plugin that changes permalink slug behavior, that affects episode slugs now, too)\n\n= 1.10.20 =\n\n**Episode Form Improvements**\n\n* Reorder components\n* Display episode title in episode meta box\n* Auto-generate media file slug based on the episode title. This is useful if your file slugs match the episode title. But don't worry, you can still change it to your liking if you prefer a different naming scheme.\n\n**Other**\n\n* Podlove Dashboard supports screen options\n* fix contribution counting in contributor table (you may have to hit the \"repair\" button in `Podlove > Support` if you still see wrong numbers)\n* fix tracking data export\n* fix missing OpenGraph metadata\n* improved redirects: added sortability and individual entries can be deactivated without being deleted\n* `contributor.id` is accessible via template API now\n\nAs mentioned before, we will be phasing out PHP 5.3 soon. Please read the corresponding blog post for more details: http://podlove.org/2014/08/14/podlove-publisher-2-phasing-out-php-5-3/\n\n= 1.10.19 =\n\n* fix caching issue (cache keys were too long in last update, resulting in no cache hits at all)\n* fix error when creating a new episode\n\n= 1.10.18 =\n\n**Improvements to media file slugs**\n\n* Slugs may contain slashes now. This allows storing asset files in subfolders and using the WordPress media uploader to manage files.\n* Media file validation is more consistent: when you get a green checkmark, the file is guaranteed to be valid and reachable.\n\n**Other**\n\n* Once we release Publisher 2.0, we will increase the minimum PHP version to 5.4 and recommend 5.5. A notice is now displayed in the system report if you are running a version requiring an upgrade.\n* Rename a method to avoid a bug in early PHP 5.3 versions\n\n= 1.10.17 =\n\n* tracking now includes range headers\n* plugin-migrations are more robust now\n* add caching for OpenGraph module\n* fix escaping in database logger\n* fix feed validator for sites not using \"pretty permalinks\"\n* fix dashboard box state saving\n* fix generation of faulty URLs when tracking was on but pretty permalinks off\n* fix auto-insertion of nonexisting templates\n* fix routing issues when `/%category%/%postname%` is used as permalink structure\n* fix rare cache concurrency issues by introducing a 24h auto-expiry\n* remove \"Critical Podlove Warnings\" — they are scary and don't help a lot\n\n= 1.10.16 =\n\n* Hotfix: remove wrong output in HTML sites\n* rework support page\n\n= 1.10.15 =\n\n**Various Fixes and Enhancements**\n\n* Supply web player API with more data: \"publicationDate\" contains an ISO-8601 date and \"show.url\" the URL to the show.\n* Auphonic UI improvement: When selecting a production, the \"Select existing production\" option disappears.\n* Don't pass `redirect=no` parameter to feed URLs\n* Ensure web player IDs are unique to avoid rendering bugs\n* Fix caching bug that lead to disappearing web player and download buttons\n* Fix redirection UI bug\n* Flush rewrite rules after migrations to avoid broken links\n\n= 1.10.14 =\n\n**Performance**\n\nA simple yet effective caching strategy has been implemented. This is used to cache rendered site segments. A complete cache invalidation happens when podcast related data changes. This should be a good start since such data rarely changes (mostly when a new episode is published). In a Multisite setup, each site handles its cache separately.\n\nThis is implemented using the [Transients API](http://codex.wordpress.org/Transients_API). By default, WordPress uses the database as a caching backend. If you want to squeeze out even more speed, consider installing a [Persistent Cache Plugin](http://codex.wordpress.org/Class_Reference/WP_Object_Cache#Persistent_Cache_Plugins) which replaces the database with a more efficient caching backend, such as memcached or APC. That might require some fiddling around, though.\n\nCaching can be deactivated in the `wp-config.php` with the following line: `define('PODLOVE_TEMPLATE_CACHE', false);`\n\n* Cache Publisher templates\n* Cache feed discovery header\n* Cache Bitlove widget\n* Other minor performance improvements\n\n**Templates**\n\n* There is now a default template containing the player and download section\n* Episode contributions can be sorted by comment and position, for example: `episode.contributors({orderby: \"comment\", order: \"DESC\"})` or episode.contributors({orderby: \"position\", order: \"ASC\"})\n* Iterate over the list of episode tags: `{% for tag in episode.tags({order: \"DESC\", orderby: \"count\"}) %} {{ tag.name }} {% endfor %}`\n\n**Other**\n\n* Display available processing time in Auphonic production box\n* Episode slugs may contain a wider variety of characters now, such as umlauts.\n* Feeds now only contain contributors with an URI. Also, output of contributors in feeds can be filtered by group and/or role.\n* New donation option for Auphonic Credits\n* Remove scary debug output on failed media file validations. This can be found in the log now.\n* Fix Auphonic authentication issue by providing the whole certificate chain\n* Fix contributor related feed rendering issue\n\n= 1.10.13 =\n\nWe decided to remove the \"Force Download\" feature. Its purpose was to guarantee that a click on a download button results in a download dialogue, rather than playing the media file in the browser. The way we implemented it worked, but came with many downsides. Just to name two of them:\n1) We doubled the traffic and significantly increased load since we had to pull all the bytes through the webserver in addition to the download server (even if both are the same).\n2) It was impossible to support HTTP range requests. That means no client was able to resume a broken or paused download. It also seemed to lead to strange behaviour in the web player.\n\nBut there is another, superior way to force downloads: configure your download server. The important setting here is [Content-Disposition](http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.5.1).\n\nIn *Apache*, you need the `headers` module (`a2enmod headers` on Debian-ish distributions). Then you can add this to your configuration:\n\n    <FilesMatch \"\\.(mp3|m4a|ogg|oga|ogv|opus|mpg|m4v|webm|flac|pdf|epub|zip)$\">\n        Header set Content-Disposition attachment\n    </FilesMatch>\n\n*lighttpd*:\n\n    $HTTP[\"url\"] =~ \"\\.(mp3|m4a|ogg|oga|ogv|opus|mpg|m4v|webm|flac|pdf|epub|zip)$\" {\n        setenv.add-response-header = (\"Content-Disposition\" => \"attachment\")\n    }\n\n*Nginx*:\n\n    if ($request_filename ~ \"\\.(mp3|m4a|ogg|oga|ogv|opus|mpg|m4v|webm|flac|pdf|epub)$\"){\n        add_header Content-Disposition 'attachment';\n    }\n\n**Other Changes**\n\n* Trim whitespace around some URLs that appear in the podcast feed.\n* Update certificate for auth.podlove.org\n* Fix an issue with saving contributors in `Podlove > Podcast Settings > Contributors`\n\n= 1.10.12 =\n\n**Tracking**\n\n* Never redirect media file URLs to trailing-slash-URLs (WordPress has a habit of adding a trailing slash to every URL via redirect. That is undesirable here, since it create two download intents).\n* Handle empty user agent strings\n* Do not write anything to tracking-database unless tracking is in analytics mode.\n\n**Other**\n\n* Compress export files via gzip.\n* Add tracking data to export files.\n\n= 1.10.11 =\n\n**Tracking**\n\n* For increased compatibility (we are looking at you, iTunes), new file URLs without parameters are used when analytics are active.\n* Add `&ptm_file=<episode-slug>.<file-extension>` parameter to the end of Parameter-URLs, so tools like wget generate a filename with a correct extension by default.\n* Feed URLs now support a `&tracking=no` parameter, which dynamically disables tracking parameters in feed enclosures. This is introduced for debugging purposes and is only mentioned here for the sake of completeness.\n* Fix PHP glitch that caused tracking to go into \"Tracking URL Parameters\" mode even when it was disabled\n\n**ADN Module**\n\n* Fix issue that could lead to repostings\n* Fix tags description\n* Messages longer than 256 characters will be shortened now and \"…\" will be appended\n\n**Other**\n\n* Fix: reenable \"force download\" option\n\n= 1.10.10 =\n\nWe discovered incompatibilities between our tracking implementation and some clients. To avoid further trouble, we are *deactivating tracking* until we solve the issue. The option is still available, we just switch it off automatically with this release and it isn't on by default any more.\n\nIf you're of the curious type, feel free to activate it and tell us any issues you run into. Thanks!\n\n= 1.10.9 =\n\n* Fix: When tracking was active but no geo-location database available, downloads would fail. This exception is handled correctly now. You can check the status of tracking and the geo-location database in `Expert Settings > Tracking`\n\n= 1.10.8 =\n\n* Feature: Services in templates can be filtered by their type. That way, you can, for example, iterate over all Twitter accounts via `podcast.services({type: \"twitter\"})`. The previous \"type\" parameter (for choosing between \"social\", \"donation\" and \"all\") has been renamed to \"category\". All default templates have been adjusted accordingly _but if you were using this API in a custom template, you need to change it_.\n* Feature: `podcast.contributors` in templates are sorted by name now. You can change the order by writing `podcast.contributors({order: \"DESC\"})`. When using grouping, each group will be sorted separately.\n* Feature: `podcast.contributors({scope: \"global-active\"})` is limited to contributors with at least one contribution in a published episode. To list contributors ignoring this limitation, use `podcast.contributors({scope: \"global\"})`. \"global-active\" is the new default.\n* Feature: Allow manual posting of ADN announcements\n* Feature: Add contributor support to ADN announcements\n* Feature: We are beginning to implement download intent tracking and statistics. As a first step, we are now tracking download intents. A following release will contain an analytics section where you can examine the statistics.\n* Feature: The feed `<link>` can be configured in `Expert Settings > Website` now. It still defaults to the home page. Other options include the episode archive and any WordPress page.\n* Enhancement: remove encryption for \"protected feed\" password to prevent autofill browser features to destroy contents\n* Enhancement: default WordPress search now covers episode subtitle, summary and chapters\n* Enhancement: add Vimeo, Gittip and about.me to services\n* Enhancement: The expert setting \"Display episodes on front page together with blog posts\" changed to \"Include episode posts on the front page and in the blog feed\". So if you set it, episodes will additionally appear in `/feed`. However, only in the form of a post. You will not find enclosures, iTunes metadata etc. in `/feed` items.\n* Enhancement: sort chapters imported from Auphonic by time\n* Enhancement: Changes to feed list: redirect URL is shown and added screen options to hide columns\n* Enhancement: Added Publisher version as an attribute to the export file. If a file is imported with a version different from the current Publisher, a warning is displayed.\n* Fix: enable group and role selection in contributor shortcodes\n* Fix: failing delayed ADN broadcast\n* Fix: stop sending ADN announcements for old episodes\n* Fix: refresh of Auphonic presets keeps current preset\n* Fix: `contributor.episodes` does not return duplicate episodes any more\n* Fix: Jabber URL scheme is now prefixed with `jabber:`\n* Fix: Display podcast subtitle in feed description (it was the blog description before)\n* Fix: Hide contributors missing a URI from feeds\n* Fix: Escaping issue when saving podcast description settings\n\n= 1.10.7 =\n\n* Feature: Direct episode access in templates via `{{ podcast.episodes({slug: 'pod001'}).title }}`\n* Feature: Episodes in templates can be filtered and ordered, for example `{{ podcast.episodes({orderby: 'title', 'order': 'ASC'}) }}`. For details, see [`podcast.episodes` documentation](http://docs.podlove.org/ref/template-tags.html#podcast)\n* Feature: Direct contributor access in templates via `{{ podcast.contributors({id: 'john'}).name }}`\n* Feature: Add shortcode `[podlove-podcast-social-media-list]`, which lists all social media accounts for the podcast\n* Feature: Add shortcode `[podlove-podcast-donations-list]`, which lists all donation accounts for the podcast\n* Feature: Add tag support for Auphonic\n* Enhancement: Add \"Save and Continue Editing\" buttons to all table based management screens\n* Enhancement: Use translations for month and day names in formatted template dates (if a language other than english is used)\n* Enhancement: Add refresh buttons for Auphonic preset selector\n* Enhancement: Pass more data to web player (as preparation for the next release)\n* Enhancement: Improved export format: It has its own namespace and a version now. Publisher version and export date are included as XML comments. XML elements are indented for better readability.\n* Remove default content for new templates\n* Fix: \"Network Activate\" works now\n* Fix: group and role filters for `[podlove-podcast-contributor-list]` shortcode work as expected now\n* Fix: Add services and donations to export format\n* Fix: `episode.player` in episode loops, outside the WordPress loop works now\n* Fix: Auphonic chapter integration issue\n* Fix: Instagram URL scheme\n\n= 1.10.6 =\n\n* Fix: contributor services will be saved correctly\n* Enhancement: add a donation column to contributor management table\n\n= 1.10.5 =\n\n**Changes to the Templating System**\n\n`episode.recordingDate` and `episode.publicationDate` are DateTime objects now. Available accessors are: year, month, day, hours, minutes, seconds. For custom formatting, use `episode.recordingDate.format(\"Y-m-d H:i:s\")` for example. Calling `episode.recordingDate` directly is still supported and defaults to the format configured in WordPress.\n\n**Other Changes**\n\n* Enhancement: Add refresh buttons for ADN patter and broadcast channel selectors\n* Fix: Avoid \"Grey Goo\" scenario of self-replicating contributors\n\n= 1.10.4 =\n\n* Hotfix: solve migration issue\n\n= 1.10.3 =\n\n**Changes to the Templating System**\n\n* New filter: `padLeft(padCharacter, padLength)` can be used to append a character to the left of the given string until a certain length is reached. Example: `{{ \"4\"|padLeft(\"0\",2) }}` returns \"04\";\n* For consistency `{{ contributor.avatar }}` is now an object. To render an HTML image tag, use `{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar} only %}`.\n* `{{ episode.duration }}` has been turned into an object to enable custom time renderings. The duration object has the following accessors: hours, minutes, seconds, milliseconds and totalMilliseconds.\n\n__DEPRECATIONS/WARNINGS__\n\n* `{{ episode.duration }}` should not be used any more. The default templates are updated but if you have used it in a custom template, you must replace it. Example: `{{ episode.duration.hours }}:{{ episode.duration.minutes|padLeft(\"0\",2) }}:{{ episode.duration.seconds|padLeft(\"0\",2) }}`\n* `{{ episode.license.html }}` and `{{ podcast.license.html }}` are deprecated. Use `{% include '@core/license.twig' %}` for the previous behaviour of choosing the correct license based on context. If you want to be more specific, use `{% include '@core/license.twig' with {'license': episode.license} %}` or `{% include '@core/license.twig' with {'license': podcast.license} %}`.\n\n**Other Changes**\n\n* Feature: ADN Module supports broadcasts\n* Enhancement: Contributor shortcode defaults to `donations=\"yes\"` to avoid confusion\n* Enhancement: `[podlove-episode-downloads]` now uses templates internally\n* Enhancement: Added 500px, Last.fm, OpenStreetMap and Soup to Services\n* Enhancement: Use custom contributor social/donation titles as icon titles\n* Enhancement: Template form has a \"Save Changes and Continue Editing\" button now\n* Enhancement: feed validation is asynchronous now and has improved performance\n* Enhancement: Licenses have a new interface and are compatible with Auphonic now: they can be imported from a finished production and are included when creating a production.\n* Enhancement: Default MySQL character set is utf8 now when creating tables\n* Enhancement: Add datepicker for episode recording date\n* Fix: all default contributors appear in new episodes again\n* Fix: change Tumblr URLs from https to http since Tumblr does not support them\n* Fix: `[podlove-podcast-contributor-list]` shows the correct contributors now\n* Fix: internal template warning when accessing empty contributor roles or groups\n* Fix: episode rendering when no files are available\n* Fix: flattr script in rss feeds\n* Fix: importer issue where sometimes modules would not activate properly\n\n= 1.10.2 =\n\n* Feature: add template filter `formatBytes` to format an integer as kilobytes, megabytes etc. Example: `{{ file.size|formatBytes }}`\n* Feature: New accessor `{{ file.id }}`. This is required to generate download forms.\n* Fix: `[podlove-episode-contributor-list]` shortcode: Firstly, the \"title\" attribute works again. Secondly, output by group is optional now and defaults to \"not grouped\" (as it was before 1.10). If you are using contributor groups and would like grouped output, use `[podlove-episode-contributor-list groupby=\"group\"]`\n* Fix: division by zero bug in statistics dashboard\n* Fix: parse time in statistics dashboard correctly as normalplaytime\n* Fix: add missing template accessor `{{ episode.recordingDate }}`\n* Remove separate \"publication date\" field in episodes. Instead, use the episode post publication date maintained by WordPress. It can be accessed via `{{ episode.publicationDate }}`\n* Fix: missing contributor-edit-icon on last entries\n\n= 1.10.1 =\n\n* Fix: podlove-episode-contributor-list shortcode: add support for \"group\" and \"role\" attributes\n* Fix: podlove-episode-contributor-list shortcode: fix broken flattr button\n* Fix: feed widget: only compress if zlib extension is loaded\n\n= 1.10.0 =\n\n**All-new, mighty Templating system**\n\nYou can now use the [Twig Template Syntax](http://twig.sensiolabs.org/documentation) in all templates. Access all podcast/episode data via the new template API. Please read the [Template Guide](http://docs.podlove.org/tut/understanding-templates.html) to get started.\n\nIf you have used templates before, please note that some shortcodes are now _DEPRECATED_. That means they still work but will be removed at some point. Following is a list of affected shortcodes and their replacements:\n\nInstead of `[podlove-web-player]`, write `{{ episode.player }}`.\n\nInstead of `[podlove-podcast-license]`, write `{{ podcast.license.html }}`.\n\nInstead of `[podlove-episode-license]`, write `{{ episode.license.html }}`.\n\nInstead of `[podlove-episode field=\"subtitle\"]`, write `{{ episode.subtitle }}`. Instead of `[podlove-episode field=\"summary\"]`, write `{{ episode.summary }}` etc. When in doubt, look at the [Episode Template Reference](http://docs.podlove.org/ref/template-tags.html#episode).\n\nChanging the podcast data shortcodes works exactly the same: Instead of `[podlove-podcast field=\"title\"]`, write `{{ podcast.title }}` etc. When in doubt, look at the [Podcast Template Reference](http://docs.podlove.org/ref/template-tags.html#podcast).\n\n**Other Changes**\n\n* Feature: The Podlove dashboard includes a section for feeds if you activate the \"Feed Validation\" module. It is intended as an overview for the state of your feeds. It shows the latest modification date, the number of entries, compressed and uncompressed size and the latest item. Additionally, you can validate your feeds against the w3c feed validator right from the dashboard.\n* Feature\" Better Bitlove integration. There is a new setting in `Podlove > Podcast Feeds > Directory Settings` called \"Available via Bitlove?\". It checks if there is a corresponding Bitlove feed and verifies it on a regular basis.\n* Feature: Support for the oEmbed format\n* New shortcode: `[podlove-episode-list]` lists all episodes including their episode image, publication date, title, subtitle and duration chronologically. This replaces the archive pages generated by the [Archivist - Custom Archive Templates](https://wordpress.org/plugins/archivist-custom-archive-templates/) plugin, if you are using it right now.\n* New shortcode: `[podlove-feed-list]` lists all public feeds\n* New shortcode: `[podlove-global-contributor-list]` shows all podcast contributors and lists related episodes.\n* New shortcode: `[podlove-podcast-contributor-list]` shows regular podcast contributors\n* Enhancement: The feed title may now include the asset title for easier discovery. This setting can be found at `Podlove > Feed Settings`\n* Changed shortcode: `[podlove-contributor-list]` is _DEPRECATED_. Please use `[podlove-episode-contributor-list]` instead.\n* Enhancement: add \"autogrow\" feature to chaptermarks text field\n* Enhancement: globally hide the migration-tool banner once dismissed rather than per-client via cookie\n* Fix: When setting the chapter asset to manual, delete all chapter caches to avoid hiccups\n* Fix: Contributor links in the backend use an ID now rather than the contributor slug. That way they work when no slug is set.\n* Fix ADN backslash escaping issue in post titles\n* Fix: all contributions can be deleted\n\n= 1.9.12 =\n* Enhancement: Take over chapters when switching from chapter asset to manual\n* Enhancement: Contributor tables look better in a wider range of themes\n* Fix: Auphonic module: Buttons cannot be clicked again while the corresponding action is in progress\n\n= 1.9.11 =\n* Enhancement: Split podcast settings into tabs.\n* Enhancement: Import/Export module supports contributors and contributions\n* Enhancement: Separate \"default contributors\" and \"podcast contributors\". You can configure default contributors in \"Contributor Settings > Defaults\" and podcast contributors in \"Podcast Settings > Contributors\". Display podcast contributors using the shortcode `[podlove-podcast-contributor-list]`.\n* Enhancements: Plethora of adjustments in contributor interfaces to avoid confusions and smoothen workflows\n* Feature: Contributions may have a public comment (to describe the context of the person), which can be displayed in contributor lists.\n* Fix: Skip contributions with missing contributors.\n\n= 1.9.10 =\n* Fix: episode images when using manual entry\n* Fix: do not include episodes in blog feed\n* Fix: paged feed calculation of number of pages when using global Publisher default\n* Fix: remove unused IDs from contributor lists\n\n= 1.9.9 =\n* Fix: several contributor episode form bugs\n* Fix: sum of all media file sizes in dashboard statistics\n* Add lost bugfix: Bundle crt file to avoid StartSSL trust issues.\n\n= 1.9.8 =\n* Enhancement: WordPress has an option to close commenting for posts after a certain amount of days. This now also applies to podcast episodes.\n* Enhancement: Fallback for Contributor Names.\n* We had to change the generated Flattr URL for contributors in episodes to a less error prone scheme. Flattr counts for those buttons will therefore reset to 0 (the actual clicks are _not_ lost! they are just not displayed).\n* fix sum of all media file sizes in dashboard statistics\n* fix license URLs\n* fix feed paging issue\n* Fix: Feed Item Limit is now displayed correctly\n* Fix: Ignore deleted contributors if they were assigned to an Episode or Podcast\n* Fix: activation / deactivation of multiple modules at once works as expected now\n* add filter \"podlove_enable_gzip_for_feeds\" to disable gzip feed compression\n* Contributor role and group columns will be hidden if no roles or groups were added\n\n= 1.9.7 =\n* fix and enhance dashboard statistics\n* gender statistics: use episode contributions instead of contributors for counting\n\n= 1.9.6 =\n* fix redirect issue after podcast migrations\n* fix legacy ADN module publishing issue\n* only show `itunes:complete` in feeds if it is set avoid a feedvalidator.org bug\n* add experimental episode fun facts in dashboard\n* add PayPal Button link in contributor settings\n* other contributor admin enhancements\n* contributor public name defaults to real name now\n\n= 1.9.5 =\n* Contributor Module improvements\n  * New icon graphics\n  * \"Contributor Groups\" as a new way to divide contributors by participation. For example, you might want to have a \"Team\" group and one for supporting contributions.\n  * No more default roles. It's just not possible to provide a sensible default set. So just add the ones you need :) (existing roles will *not* be deleted)\n  * The contributors defined in `Podcast Settings > Contributors` are now the default contributors for new episodes\n  * Reworked contributor management table. Better use of space, hideable columns, avatars and more.\n  * Reworked episode contributor table. Avatars, edit links and more.\n  * Support for more services\n  * ... and a bunch of other tweaks\n* Web Player Update: compatible with WordPress theme \"Twenty Fourteen\"\n* Fix: don't gzip feeds when zlib compression is active\n* Fix: episode media file checkbox width for WP3.8\n* Fix: menu icons for WP3.8\n\n= 1.9.4 =\n* Fix: gzip feeds on compatible systems only (avoids failing feed generation)\n* Fix: Feed paging (again)\n\n= 1.9.3 =\n* Fix: provide global feed limit default on setup\n* Fix: managing contributor roles no longer outputs permission issues\n* Fix: corrected a faulty \"Add New\" contributor link\n* Fix: paged feeds were broken\n\n= 1.9.2 =\n* Fix: _Module: Contributors_ prevent initial migration to import duplicate contributors\n* Fix: _Module: Contributors_ Fix faulty default roles\n\n= 1.9.0 / 1.9.1 =\n\n**New Module: Contributors**\n\nPodcasts are not possible without their active communities. Huge contributions are being made behind the scenes and nobody notices except the podcaster. The contributors module shines light on all those diligent people. It's now easy to manage contributors of an episode and list them on the blog. The list contains references to their social profiles and the donation service Flattr. Shortcode to display them in an episode post: [`[podlove-contributor-list]`](http://docs.podlove.org/ref/template-tags.html#contributors).\n\n**Simple Protected Feeds**\n\nYou can now protect some or all of your feeds using HTTP authentication. Authenticate via a defined username and password or use the WordPress user database as backend.\n\n**License Selector**\n\nWe built an interface to generate a Creative Commons license for your podcast and episodes. You can still use a custom URL and name if you don't want a CC license. Use `[podlove-podcast-license]` and `[podlove-episode-license]` to display them in your episode posts.\n\n**Other Changes**\n\n* Feature: Add \"Expert Settings\" option to always redirect to media files instead of forcing a browser download. This is interesting for you if you want to minimize traffic on your server hosting the Publisher.\n* Feature: add global setting to configure feed item limits\n* Feature: Set \"itunes:explicit\" tag per episode if you want to (you have to activate the feature in the expert settings)\n* Enhancement: Feeds are delivered with gzip compression if possible\n* Enhancement: Support for temporary redirects in expert settings\n* Fix: keep ?redirect=no flag in paged feeds\n* Fix: _Module: Import/Export_ Importing episodes no longer causes floods of ADN posts.\n* Fix: _Module: Auphonic_ respect Auphonic chapter offset\n* _DEPRECATED_: `podlove-contributors` shortcode. Use `podlove-contributor-list` instead\n\n= 1.8.13 =\n* Feature: Update Web Player to 2.0.17 (for realsies). It fixes an issue with icon/font display.\n\n= 1.8.12 =\n* Feature: Update Web Player to 2.0.17\n* Bugfix: Fix PHP 5.3 issue in import module\n\n= 1.8.11 =\n* Feature: New module for Import/Export. Now you can easily move all your podcast data to another WordPress instance.\n* Feature: Add support for `<itunes:complete>` tag. If there won't be any additional episodes, you can go to `Podlove > Podcast Settings` and activate this setting.\n* Bugfix: Bundle crt file to avoid StartSSL trust issues.\n\n= 1.8.10 =\n* Hotfix: Removes incompletely updated license feature which wasn't supposed to be in that release in the first place. Sorry!\n\n= 1.8.9 =\n* Feature: Update Web Player to 2.0.16\n* Enhancement: Render Twitter and OpenGraph tags using a DOM-Generator to avoid all possible escaping issues.\n* Enhancement: Allow multiple mime types for web player config slots. Fixes an issue with Firefox and Opus.\n* Enhancement: I CAN HAZ SECURETEH?! auth.podlove.org haz https nao.\n* Bugfix: Module settings screen rendering issue with PHP 5.3\n* Bugfix: Fix link to shortcode documentation\n\n= 1.8.7 / 1.8.8 =\n* Enhancement: Refined Auphonic Workflow: Always import duration and slug; new option to automatically start productions after creation; new option to automatically publish episodes as soon as the production is ready\n* Hotfix: escaping issue\n\n= 1.8.6 =\n* Enhancement: Change feed redirect hook and priority so it works better with Domain Mapping plugin\n* Enhancement: Extend OpenGraph metadata by post thumbnail and episode description (thanks smichaelsen!)\n* Feature: Update Web Player to 2.0.15\n* Fix: Solve rare issue where first chapter line would be ignored\n* Fix: Firefox display issue in migration assistant\n\n= 1.8.5 (2013-08-11) =\n* Fix: JavaScript issue preventing certain UI elements from working correctly (Tagging, Auphonic, …)\n\n= 1.8.4 (2013-07-27) =\n* Fix: Performance issue in Auphonic plugin\n\n= 1.8.3 (2013-07-27) =\n* Enhancement: dates with leading zeros in Auphonic module\n* Enhancement: Auphonic UI smoothifications\n* Enhancement: Update assets after successful production\n\n= 1.8.2 (2013-07-27) =\nAuphonic integration Enhancements\n\n* Preset is only applied once\n* Add Text for \"Open Production\" button\n* \"Start Production\" button more prominent\n\n= 1.8.1 (2013-07-27) =\n* Fix Release\n\n= 1.8.0 (2013-07-27) =\n* Auphonic Module Update. You are now able to manage productions directly from within the Publisher without visiting Auphonic at all. As always, any feedback is more than welcome.\n* App.net Module Update. Support for Patter, language annotations and delayed posting.\n* Enhancement: Control sequence in which audio elements are printed in the web player. This encourages browsers to use superior codecs (rather than mp3).\n\n= 1.7.3 (2013-07-18) =\n* Enhancement: Show expected and actual mime type in log when an error occurs\n* Bugfix: Fix Bitlove integration\n* Bugfix: Correctly hide content in password protected posts\n* Bugfix: ADN Plugin announced new episode every time the episode got saved\n* Fix some PHP 5.4 Strict warnings\n\n= 1.7.2 (2013-07-11) =\n* Feature: Update Web Player to 2.0.13\n* Bugfix: Feed web player with existing/valid files only\n* Bugfix: Downloads work without JavaScript enabled\n* Bugfix: Episode previews should work now\n* Bugfix: Migration Assistant: you are now able to import file slugs containing dots\n* Bugfix: Fix podlove_alternate_url issue\n\n= 1.7.1 (2013-07-06) =\n* Logging Module: Deactivate sending of mails until we figure out what causes some misbehaviours\n* Enhancement: System Report: check for SimpleXML availability\n* Bugfix: ADN Announcements should work with all kinds of templates now\n\n= 1.7.0-alpha (2013-07-03) =\n* New Module: App.net. Right now, it lets you announce new podcast episodes on ADN whenever you publish a new one. It's the groundwork for more ADN integrations. (Thanks @chemiker!)\n* New Module: Auphonic. We did not shy away from writing a completely new module to present to you the best Auphonic integration the world has seen in a WordPress plugin. It replaces the previous one (\"Auphonic Production Data\"). You are now able to import Auphonic production data without the need for a production description file. Like the ADN module, this lays the groundwork for much deeper Auphonic integration. (Thanks @chemiker!)\n* Enhancement: Return the correct content type when initiating a download so devices may choose intelligently whether to save the file or open it in a certain application.\n* Enhancement: Remove download button styles so the style adjusts based on used browser and theme\n* Bugfix: Fix incompatibility to some file name schemes\n* Bugfix: Fix 404 status for paged feedburner feeds\n\n= 1.6.11-alpha =\n* Bugfix: use NPT library\n\n= 1.6.10-alpha =\n* Fix release issues\n\n= 1.6.7-alpha =\n* Enhancement: Move file types settings to expert settings\n* Enhancement: Saving a template redirects to template list\n* Enhancement: System Report is a readonly textarea\n* Enhancement: Group modules\n* Enhancement: When creating an asset: if that web player slot is not taken yet, assign it automatically\n* Enhancement: Accept time formats with minutes > 59 if no hours are given\n* Bugfix: Fix \"Chapters Visibility\" setting\n\n= 1.6.6-alpha =\n* Enhancement: When validating, ignore timeouts (so files don't disappear from feeds just because one request took too long)\n* Enhancement: When episode permalinks are invalid, try to autoresolve by switching to \"Use Post Permastruct\"\n* Bugfix: Fix some expert setting migration issues\n* Bugfix: Hide invalid media files from downloads\n\n= 1.6.5-alpha =\n* Feature: Feeds are sortable\n* Feature: You can revalidate single media files in the dashboard\n* Enhancement: Use pretty status icons\n* Enhancement: Add \"sortable handle\" for asset and feed lists, so the sortability feature is more discoverable\n* Enhancement: Add \"Podlove\" entry to WordPress toolbar\n* Enhancement: Organize \"Expert Settings\" into tabs\n* Enhancement: Don't log \"File not Modified\"\n* Bugfix: Activate feature \"Activate asset for all existing episodes\" for pending episodes\n* Bugfix: Solve issue with chapter asset cache invalidation\n* Bugfix: Solve chapter encoding issue when chapters start with umlauts\n* Bugfix: Fix video display in some themes\n* Other small UI changes in various places\n\n= 1.6.4-alpha =\n* Bugfix: use manual chapter entries if available\n* Bugfix: PSC assets work properly\n* Bugfix: URL magic doesn't interfere with other post types\n* Bugfix: deactivate preload in web player\n\n= 1.6.3-alpha =\n* Bugfix: \"Display episodes on front page together with blog posts\" works again\n* Bugfix: chapters at 0 seconds are not ignored any more\n* Bugfix: correctly show feed title in deletion confirmation\n* Bugfix: handle missing/invalid PSC file with appropriate grace\n* Bugfix: remove player from feed\n* Bugfix: fix false negatives in error log; reenable logging-mails\n* Bugfix: fix timezone in logs\n\n= 1.6.2-alpha =\n* Bugfix: fix template autoinsert migration issue\n\n= 1.6.1-alpha =\n* Bugfix: fix call-time pass-by-reference\n* Bugfix: deactivate logging-mails until we find out what's wrong\n\n= 1.6.0-alpha =\n* Feature: New modules \"Asset Validation\" and \"Logging\". Automatically verify assets once in a while (fresh posts will be validated more often than old posts). Detailed logging in Podlove dashboard. Receive an email when all episode assets are unavailable.\n* Feature: always print PSC in feed if any chapter format is available (psc, mp4chaps, json)\n* Feature: upgrade web player to v2.0.10\n* Enhancement: template autoinsert settings are on templates page now\n* Enhancement: correctly fall back to podcast image when episode image is activated but missing\n* Enhancement: various UI fixes (thanks @MaZderMind)\n* Enhancement: improve feed deletion dialogue\n* Enhancement: default title for episode assets is file format title\n* Bugfix: solve permalink issue after migrations\n* Bugfix: migrate comment hierarchy correctly\n\n= 1.5.4-alpha =\n* Feature: PubSubHubbub support via new module\n* Enhancement: Check for iconv availability in system report\n* Turn permalink compatibility up to eleven\n\n= 1.5.3-alpha =\n* Bugfix: more robust permalink fix\n\n= 1.5.2-alpha =\n* Bugfix: Fix using the same permalink structure / 404 on pages\n\n= 1.5.1-alpha =\n* Enhancement: episodes may share the same permalink structure with WordPress posts\n* Enhancement: episode archive url can be configured\n* Enhancement: run system report more intelligently\n* Enhancement: Auphonic module works more smoothly for new episodes\n* Enhancement: Fallback to 302 redirects for HTTP/1.0 clients\n* Enhancement: Confirm before deleting feeds and templates\n* Enhancement: Parse time strictly following the NPT specification: http://www.w3.org/TR/media-frags/#npttimedef\n* Bugfix: don't use feed redirect when a feed archive page is specified\n\n= 1.4.8-alpha =\n\nMinor fixes and improvements:\n\n* feed: remove style tags from content:encoded (feedvalidator.org warning)\n* feed: ensure description precedes content:encoded (feedvalidator.org warning)\n* prevent feed proxy issue\n* `HEAD` requests for paged feeds return correct responses\n* enable paging for `/podcast` archives\n* add description to redirect settings\n* rename \"record date\" to \"recording date\"\n\n= 1.4.7-alpha =\n* Hotfix: ignore empty redirect rules\n\n= 1.4.6-alpha =\n* Bugfix: The podcast archive is available via `/podcast` again.\n\n= 1.4.5-alpha =\n* Enhancement: always show critical errors found by system report\n* Enhancement: flush rewrite rules after migration and feed changes\n* Enhancement: redirect settings support URL parameters\n\n= 1.4.4-alpha =\n* Feature: configure permanent redirects in Expert Settings\n* Bugfix: fix feed url generation for \"default style\" permalinks\n* Bugfix: migration assistant shows enclosure errors/warnings\n* Bugfix: add missing atom prefix in feed link elements\n* Bugfix: generate valid episode permalinks for \"Default\"/\"Not Pretty\" permalink settings\n* Bugfix: change default episode permalink structure from `%podcast%` to `podcast/%podcast%` to avoid conflicts with those setups using %postname% as WordPress permalink — which is quite common.\n\n= 1.4.3-alpha =\n* Bugfix: fix system report issue\n* Bugfix: fix feed setting \"No limit. Include all items.\"\n\n= 1.4.2-alpha =\n* Bugfix: add Auphonic metadata file type\n* Bugfix: fix bug regarding limiting feed items\n\n= 1.4.1-alpha =\n* Bugfix: reactivate /podcast url\n\n= 1.4.0-alpha =\n* Feature: \"Soft Launch\" for migration tool. It isn't activated by default but if you are adventurous, feel free to give it a try. Any feedback is greatly appreciated!\n* Feature: Support paged feeds (RFC5005) so clients may always fetch all episodes even if the default feed only contains the most recent episodes\n* Feature/Change: Similar to the web player setting, you now can insert templates automatically at the beginning or end of a post. You could even create multiple templates, one to append and one to prepend. This replaces the previous template-autoinsert feature.\n* Feature: New module \"Auphonic Production Data\". Thanks @tobybaier!\n* Enhancement: Update Web Player to v2.0.7\n* Enhancement: open graph title is podcast title\n\n= 1.3.30-alpha =\n* Feature: Option to autoinsert web player at beginning or end of post\n* Feature: Add \"Support\" page including a system report\n* Enhancement: Add .post class to article-classes list to improve theme compatibility\n* Bugfix: Fix feed validation mixup\n* Bugfix: Support \"future publishing\" of episodes (thanks Marc!)\n\n= 1.3.29-alpha =\n* Bugfix: Fix some media file mixups\n\n= 1.3.28-alpha =\n* Feature: Two new episode fields `publication_date` and `record_date`. Accessible via episode shortcode. Must be enabled in expert settings.\n* Feature: Assets can be sorted via drag'n'drop. Influences download button/list order.\n* Bugfix: fix \"No More Enclosures\" feature. I was using a deprecated hook\n* Enhancement: upgrade Podlove Web Player to 2.0.5\n* Enhancement: move episode asset url to expert settings\n* Change: Drop support for Atom feeds\n* Change: Remove support for mnemonic and Episode Assistant module\n\nIn the beginning, everything evolved around the episode numbers and the\nmnemonic. Then, it made sense to support this concept by something like the\nepisode assistant.\n\nNow, the mnemonic is merely an afterthought. It's used by no part of\nthe system except the episode assistant. And this doesn't do a lot that\ncan't be done without it either. So we decided to drop both for now.\n\nA similar concept might return once we tackle stuff like seasons.\n\n= 1.3.27-alpha =\n* Enhancement: enforce trailing slash at the end media file base url\n* Enhancement: fix huge download-select-font\n* Enhancement: doublecheck curl availability\n* Bugfix: double quote escaping for Web Player title, subtitle and summary\n\n= 1.3.26-alpha =\n* Enhancement: upgrade Podlove Web Player to 2.0.4\n\n= 1.3.25-alpha =\n* Feature: Setting for Web Player to show or hide chapters by default\n* Enhancement: Open Graph now correctly excludes non-audio assets\n* Enhancement: \"File not found\" errors now result in some debug output which may help tracing the issue\n* Enhancement: upgrade Podlove Web Player\n* Bugfix: Generated Template shortcodes now use the \"id\" attribute rather than \"title\"\n\n= 1.3.24-alpha =\n* Enhancement: remove mediaelementjs demo files\n\n= 1.3.23-alpha =\n* Enhancement: upgrade Podlove Web Player\n* Enhancement: improve handling of url_fopen setting\n* Enhancement: feed item limit is now a select box. default is now \"all\" instead of \"WordPress Default\"\n\n= 1.3.22-alpha =\n* Hotfix: solve White Screen of Death issue for PHP 5.4\n\n= 1.3.21-alpha =\n* Bugfix: allow deletion of unused assets\n* Enhancement: if an asset shouldn't be deleted, display where it's in use (allow deletion anyway)\n* Enhancement: Downloads redirect to file if `allow_url_fopen` is disabled.\n\n= 1.3.20-alpha =\n* Enhancement: always add a trailing slash to media file base url\n* Bugfix: trying to fix escaping part whatnotsoever\n\n= 1.3.19-alpha =\n* Hotfix: slugs are not forced into lowercase any more\n\n= 1.3.18-alpha =\n* Feature: Module for Bitlove.org support! Adds links to torrent-files to the downloads-section of your episodes.\n* Feature: add video support for web player\n* Enhancement: fix a (possibly rare) memory bug when downloading files\n* Enhancement: enable episodes on home page by default\n* Enhancement: change default download widget style to the select-thingy\n* Bugfix: fix feed warning\n\n= 1.3.17-alpha =\n* Bugfix: fix issue with 3rd party custom post types\n* Enhancement: improve Feed Settings screen\n\n= 1.3.16-alpha =\n* Feature: new style for file downloads `[podlove-episode-downloads style=\"select\"]`\n* Enhancement: Solve feed url issues:\n** ensure validity on save\n** support non-pretty url format\n* Enhancement: un-default some modules: episode assistant, twitter card summary\n* Enhancement: fix asset & feed setting redirect issue\n* Enhancement: add caption file types\n* Enhancement: new icons!\n* Enhancement: allow underscores and dots in slugs\n* Bugfix: fix issue with multiple backslash-escapings\n\n= 1.3.15-alpha =\n* Hotfix: fix 404 issue concerning episode prefixes and posts\n\n= 1.3.14-alpha =\n* Feature: ajaxy asset revalidation in dashboard\n* Feature: duration support for web player\n* Feature: add option to provide web players with opus format\n* Enhancement: slightly improved web player settings pane\n* Enhancement: deprecate [podlove-template title=\"\"] in favor of [podlove-template id=\"\"] for clarity\n* Enhancement: move category support for episodes into a module\n* Enhancement: force feed & episode slugs into url conformity\n* update plugin description and add a FAQ section\n\n= 1.3.13-alpha =\n* Bugfix: Podcast model works with `switch_to_blog` now\n\n= 1.3.12-alpha =\n* Enhancement: don't embed cover image fallback in feed as episode image when there is no episode image\n* Feature: add action link for assets to enable it for all existing episodes. useful when adding a new asset for an existing podcast\n\n= 1.3.11-alpha =\n* Enhancement: Image input fields try to show pasted image immediately\n* Enhancement: remove unused \"post episode to show\" setting\n* Bugfix: fix asset preview glitch when changing the episode slug\n* Bugfix: fix GUID upgrade migration\n\n= 1.3.10-alpha =\n* Hotfix: too much escaping when `get_magic_quotes` is on\n\n= 1.3.9-alpha =\n* Enhancement: rectify feed generator title\n* Bugfix: add missing sql escaping\n\n= 1.3.8-alpha =\n* Bugfix: fix episode image fallback to podcast image\n\n= 1.3.7-alpha =\n* Enhancement: In feed settings, URL preview updates live now\n* Enhancement: \"Add New\" button in blank list table views\n* Enhancement: display `<language>` tag in RSS channel and correct xml:lang in ATOM\n* Enhancement: forbid asset deletion when used in feed or web player\n* Bugfix: Templates list view highlights template preview correctly now for more than one entry\n* Bugfix: remove duplicate rel=\"self\" entry from RSS feeds\n* Bugfix: correct escaping for all input fields\n* Bugfix: fix 404s when using an empty episode url prefix\n\n= 1.3.6-alpha =\n* Bugfix: Minor WordPress 3.5 compatibility issue\n* Bugfix: Use correct shortcodes in default template\n* Enhancement: Add support for `[podlove-episode field=\"title\"]`\n* Enhancement: Improve auto-updating of media files. It will now work correctly without the need to save the post after changing the media file slug. It updates every time you change the slug and lose focus of the input field.\n\n= 1.3.5-alpha =\n* Bugfix: pages and menu items don't appear unexpectedly in main loop any more\n* Bugfix: when using the WordPress importer, don't create new GUIDs\n* Enhancement: rename GUID meta so it doesn't appear as custom field\n\n= 1.3.4-alpha =\n* Hotfix: fix asset creation issue\n\n= 1.3.3-alpha =\n* Enhancement: Use episode image fallback to podcast image in webplayer.\n\n= 1.3.2-alpha =\n* Feature: When using manual mp4chaps style chapter marks, the Publisher generates \"Podlove Simple Chapters\" for the feed automatically. Includes link support using chevrons (example: `00:00:00 Intro <http://podlove.org>`).\n\n= 1.3.1-alpha =\n* update web player to 1.2.1\n\n= 1.3.0-alpha =\n* Feature: [Podlove Deep Linking](http://podlove.org/deep-link/) support\n* Feature: support for new web player\n* Bugfix: enable tag and category search results for all post types\n* Bugfix: Feed item limit setting works now\n* Bugfix: avoid rare curl warning\n* Bugfix: improve feed validity\n* Enhancement: remove unused feed setting `show description`\n* Enhancement: Podlove feeds don't override /feed/* WordPress feeds any more\n* Enhancement: Rename plugin to \"Podlove Podcast Publisher\"\n* Enhancement: Move asset assignments from podcast settings to asset settings\n\n= 1.2.24-alpha =\n* Bugfix: don't show milliseconds in feed so feedvalidator.org stops complaining\n\n= 1.2.22/23-alpha =\n* Fix deployment bug, delete unused files from SVN\n\n= 1.2.21-alpha =\n* Bugfix: check for asset relations (not just media file relations) when trying to delete assets\n* Bugfix: asset form can handle file types using brackets now\n* Bugfix: There was an undocumented way to just show episodes on the front page. However, this made using static pages as front page unusable. So for now, this functionality has been deactivated. The expert option to display both episodes and articles on the front page is not affected and will continue to work.\n* Enhancement: duration is now normalized and can be printed full (HH:MM:SS.mmm) or HH:MM:SS using `[podlove-episode field=\"duration\" format=\"full/HH:MM:SS\"]`\n* Enhancement: curl requests set user agent\n\n= 1.2.20-alpha =\n* Bugfix: forbid deletion of episode assets referenced by existing media files\n* Bugfix: fix episode asset type selector\n\n= 1.2.19-alpha =\n* Feature: add episode image shortcode `[podlove-episode field=\"image\"]`\n* Bugfix: fix some bugs\n* Enhancement: when creating new form entries, the user is now redirected to the index page rather than the edit form\n\n= 1.2.18-alpha =\n* Feature: 4 new podcast fields: publisher_name, publisher_url, license_name, license_url\n* Feature: Shortcode `[podlove-podcast]` to access podcast data. See [Shortcode Documentation](https://github.com/eteubert/podlove/wiki/Shortcodes) for more details.\n* Feature: Shortcode `[podlove-episode]` to access episode data. *all previous episode accessors are deprecated!* See [Shortcode Documentation](https://github.com/eteubert/podlove/wiki/Shortcodes) for more details.\n* Feature: Add support for tags and categories in episodes.\n* Feature: Chapter File (txt and psc) as episode asset\n* Feature: Feed redirects can be a) turned off and b) permanent c) temporary\n* Feature: Module for Twitter Card support\n* Enhancement: Minor template editor enhancements and updated default template.\n* Enhancement: Enable revisions for episodes.\n* Enhancement: RSS/Atom cleanup. Less WordPress, more Podlove.\n* Enhancement: UI improvements in episode asset forms\n* Enhancement: Menu reorganisation. Moved important stuff up, expert stuff down. Separate site for modules.\n\n= 1.2.17-alpha =\n* Nothing. Just some WordPress-Plugin-Directory-Thingamajig-Version-Foobar.\n\n= 1.2.16-alpha =\n* Feature: Episode templates. Go to `Podlove > Templates` to find out more. See [Shortcode Documentation](https://github.com/eteubert/podlove/wiki/Shortcodes) for more details.\n* Feature: Custom GUID for episodes. A GUID in the form of \"podlove-`time`-`hash`\" is generated for each new episode. It removes the ambiguity of the permalink-ish looking WordPress GUID. Bonus: If you need podcatchers to redownload all media files (maybe you detected a glitch in your files and fixed it), you are now able to change the GUID to achieve that.\n* Enhancement: remove episode excerpt support in favor of episode summary\n* Bugfix: Short Episode Routing compatibility\n\n= 1.2.15-alpha =\n* Bugfix: remove all Show model references for now\n* Enhancement: proper summary/description feed elements\n\n= 1.2.14-alpha =\n* Enhancement: rename \"media locations\" to \"episode assets\" for clarity\n* Enhancement: rename \"podlove formats\" to \"file types\" for clarity\n* Enhancement: start to rework validation section\n* Enhancement: check for episode files when slug changes\n\n= 1.2.13-alpha =\n* Enhancement: use episode summary as excerpt if available\n* Bugfix: episode assistant file slugs respect mnemonic case\n* Bugfix: solve 404 issue with pages\n\n= 1.2.12-alpha =\n* Bugfix: Minor JavaScript glitch\n\n= 1.2.11-alpha =\n* New Module: Contributors Taxonomy — display with shortcode `[podlove-contributors]` (go to `Podlove > Settings` to activate the module)\n\n= 1.2.10-alpha =\n* Feature: Add Shortcodes to display episode data: `[podlove-episode-subtitle] [podlove-episode-summary] [podlove-episode-slug] [podlove-episode-duration] [podlove-episode-chapters]`\n* Feature: Add Opus File Format ([see Auphonic blog for more info](http://auphonic.com/blog/2012/09/26/opus-revolutionary-open-audio-codec-podcasts-and-internet-audio/))\n* Feature: Show red warning in dashboard if one of the following podlove settings is missing: `title`, `mnemonic`, `base url`\n* Enhancement: Remove pagination from formats settings page\n\n== Upgrade Notice ==\n\n= 2.0.0 =\n\nUpgrade only if you are on PHP 5.4 or higher.\n\n= 1.2.0-alpha =\nBefore you update, delete all shows but one to ensure your important data stays. Watch out: Your feed URLs will change!\n"
  },
  {
    "path": "client/.tool-versions",
    "content": "nodejs 18.18.2\n"
  },
  {
    "path": "client/config.local.template.js",
    "content": "// Template for local development configuration\n// Copy this file to config.local.js and customize\nwindow.devConfig = {\n  user: 'YOUR_USERNAME',\n  applicationPassword: 'YOUR_APPLICATION_PASSWORD',\n  baseUrl: 'http://publisher.local',\n}\n"
  },
  {
    "path": "client/index.html",
    "content": "<html>\n  <head>\n    <meta http-equiv=\"content-type\" content=\"text/html; charset=utf-8\" />\n    <title>Podlove Publisher Client Development Environment</title>\n    <link rel=\"shortcut icon\" href=\"#\" />\n  </head>\n\n  <body class=\"bg-gray-500 p-2\">\n    <div data-client=\"podlove\">\n      <podlove-media-files></podlove-media-files>\n      <div class=\"h-5\"></div>\n      <podlove-auphonic></podlove-auphonic>\n      <div class=\"h-5\"></div>\n      <podlove-description></podlove-description>\n      <div class=\"h-5\"></div>\n      <podlove-chapters></podlove-chapters>\n      <div class=\"h-5\"></div>\n      <podlove-transcripts></podlove-transcripts>\n      <div class=\"h-5\"></div>\n      <podlove-contributors></podlove-contributors>\n      <div class=\"h-5\"></div>\n      <podlove-related-episodes></podlove-related-episodes>\n      <div class=\"h-5\"></div>\n      <podlove-soundbite></podlove-soundbite>\n    </div>\n\n    <script type=\"module\" src=\"./src/client.ts\"></script>\n    <script src=\"./config.local.js\"></script>\n\n    <script>\n      window.addEventListener('load', () => {\n        // Use config from external file config.local.js\n        const config = window.devConfig || {};\n        const user = config.user || 'admin';\n        const applicationPassword = config.applicationPassword || '';\n        const baseUrl = config.baseUrl || 'http://publisher.local';\n\n        // First fetch the JS hook to get the variables\n        fetch(`${baseUrl}/?hook=podlove-js-hook`)\n          .then(response => response.text())\n          .then(jsContent => {\n            // Create and execute the script to set the variables\n            const script = document.createElement('script');\n            script.textContent = jsContent;\n            document.body.appendChild(script);\n\n            // Continue with the episode initialization after variables are set\n            return fetch(`${baseUrl}/wp-json/podlove/v2/episodes`);\n          })\n          .then((res) => res.json())\n          .then(({ results }) => {\n            if (!results.length) {\n              throw new Error('Missing Episodes')\n            }\n\n            return results[0]\n          })\n          .then((episode) =>\n            fetch(`${baseUrl}/wp-json/podlove/v2/episodes/${episode.id}`)\n          )\n          .then((res) => res.json())\n          .then(({ id, post_id }) => {\n            const data = {\n              baseUrl: baseUrl,\n              api: {\n                base: `${baseUrl}/wp-json/podlove`,\n                auth: btoa(`${user}:${applicationPassword}`),\n              },\n              post: {\n                id: post_id,\n              },\n              episode: {\n                id,\n              },\n            }\n\n            const mergedData = { ...data, ...window.PODLOVE_DATA }\n            window.initPodloveUI(mergedData)\n          })\n          .catch(error => {\n            console.error('Error initializing Podlove UI:', error);\n          });\n      })\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"scripts\": {\n    \"build\": \"vue-tsc --noEmit && NODE_ENV=production vite build --base=/wp-content/plugins/podlove-publisher/client/dist/\",\n    \"serve\": \"vite build --watch --base=/wp-content/plugins/podlove-publisher/client/dist/\",\n    \"dev\": \"vite\"\n  },\n  \"devDependencies\": {\n    \"@babel/types\": \"7.24.7\",\n    \"@tailwindcss/forms\": \"^0.5.11\",\n    \"@types/lodash\": \"^4.17.24\",\n    \"@types/redux-actions\": \"^2.6.5\",\n    \"@types/uuid\": \"^9.0.7\",\n    \"@vitejs/plugin-vue\": \"^6.0.5\",\n    \"autoprefixer\": \"^10.4.27\",\n    \"cssnano\": \"^7.1.3\",\n    \"postcss\": \"^8.4.33\",\n    \"postcss-import\": \"^16.1.1\",\n    \"postcss-prefix-selector\": \"^2.1.1\",\n    \"prettier\": \"^3.8.1\",\n    \"tailwindcss\": \"^3.4.19\",\n    \"typescript\": \"^5.9.3\",\n    \"uuid\": \"^9.0.0\",\n    \"vite\": \"^8.0.3\",\n    \"vue-tsc\": \"^3.2.6\"\n  },\n  \"dependencies\": {\n    \"@babel/types\": \"7.24.7\",\n    \"@headlessui/vue\": \"^1.7.23\",\n    \"@heroicons/vue\": \"^2.2.0\",\n    \"@podlove/utils\": \"5.12.2\",\n    \"@podlove/web-player\": \"^5.13.0\",\n    \"@popperjs/core\": \"^2.11.8\",\n    \"axios\": \"^1.13.6\",\n    \"lodash\": \"^4.17.23\",\n    \"redux\": \"^5.0.1\",\n    \"redux-actions\": \"^3.0.3\",\n    \"redux-saga\": \"^1.4.2\",\n    \"redux-vuex\": \"^4.0.1\",\n    \"reselect\": \"^5.1.1\",\n    \"vue\": \"^3.5.31\",\n    \"vue3-popper\": \"^1.5.0\"\n  }\n}\n"
  },
  {
    "path": "client/postcss.config.js",
    "content": "module.exports = {\n  plugins: [\n    require('postcss-import'),\n    require('tailwindcss/nesting'),\n    require('tailwindcss'),\n    require('autoprefixer'),\n    ...(process.env.NODE_ENV === 'production' ? [ require('cssnano') ] : []),\n    require('postcss-prefix-selector')({ prefix: '*[data-client=\"podlove\"]' }),\n  ]\n}\n"
  },
  {
    "path": "client/src/assets/index.d.ts",
    "content": "declare module '*.png' {\n    const value: any;\n    export = value;\n}\n"
  },
  {
    "path": "client/src/client.ts",
    "content": "import { createApp } from 'vue'\nimport { provideStore } from 'redux-vuex'\nimport { store } from '@store'\n\nimport modules from './modules'\nimport { init } from './store/lifecycle.store'\nimport translationPlugin from './plugins/translations'\n\nimport './style.css'\n\nwindow.addEventListener('load', () => {\n  document.querySelectorAll('[data-client=\"podlove\"]:not([data-loaded=\"true\"])').forEach((elem) => {\n    elem.setAttribute('data-loaded', 'true')\n\n    const app = createApp({\n      components: {\n        ...modules,\n      }\n    })\n\n    provideStore({ store, app })\n\n    app.use(translationPlugin)\n    app.mount(elem)\n  });\n});\n\n(globalThis as any).initPodloveUI = (data: any) => {\n  store.dispatch(init(data))\n}\n"
  },
  {
    "path": "client/src/components/button/Button.vue",
    "content": "<template>\n  <button\n    type=\"button\"\n    class=\"inline-flex items-center focus:outline-none focus:ring-2 border border-transparent shadow-sm whitespace-nowrap disabled:opacity-75\"\n    :disabled=\"disabled\"\n    :class=\"[variantClass, sizeClass]\"\n  >\n    <slot />\n  </button>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, PropType } from '@vue/runtime-core'\n\nexport type ButtonType =\n  | 'primary'\n  | 'primary-disabled'\n  | 'secondary'\n  | 'secondary-disabled'\n  | 'submit'\n  | 'danger'\n  | 'default'\nexport type ButtonSize = 'small' | 'medium' | 'large'\n\nexport default defineComponent({\n  props: {\n    variant: {\n      type: String as PropType<ButtonType>,\n      default: 'default',\n    },\n    size: {\n      type: String as PropType<ButtonSize>,\n      default: 'medium',\n    },\n    disabled: {\n      type: Boolean,\n      default: false,\n    },\n  },\n\n  computed: {\n    variantClass() {\n      switch (this.variant) {\n        case 'default':\n          return `focus:outline-none text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-100`\n        case 'primary':\n          return `focus:ring-offset-2 text-white focus:ring-indigo-500 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300`\n        case 'primary-disabled':\n          return `focus:ring-offset-2 text-white focus:ring-indigo-500 bg-indigo-600 opacity-50 cursor-not-allowed`\n        case 'secondary':\n          return `focus:ring-offset-2 text-indigo-700 focus:ring-indigo-500 bg-indigo-100 hover:bg-indigo-200 disabled:bg-indigo-50`\n        case 'secondary-disabled':\n          return `focus:ring-offset-2 text-indigo-700 focus:ring-indigo-500 bg-indigo-100 disabled:bg-indigo-50 opacity-50 cursor-not-allowed`\n        case 'danger':\n          return `bg-red-600 text-white hover:bg-red-700 focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:bg-red-300`\n      }\n    },\n\n    sizeClass() {\n      switch (this.size) {\n        case 'small':\n          return `px-2.5 py-1.5 text-xs font-medium rounded `\n        case 'medium':\n          return `px-3 py-2 text-sm leading-4 font-medium rounded-md`\n        case 'large':\n          return `px-6 py-3 text-base font-medium rounded-md`\n      }\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/components/combobox/Combobox.vue",
    "content": "<template>\n  <Combobox\n    :model-value=\"selectValues\" \n    :multiple=\"multiple\"\n    @update:model-value=\"selectItem($event)\"\n    >\n    <div class=\"\n      relative\n      mt-1\n      focus-visible:border-indigo-500\n      focus:ring-indigo-500\n      \">\n      <ComboboxButton\n        class=\"\n          w-full\n          flex \n          items-center \n        \"\n        @click=\"resetQuery\"\n      >\n      <ComboboxInput\n        @change=\"onChange\"\n        class=\"\n          relative\n          py-2\n          pl-3\n          w-full\n          bg-white\n          rounded-lg\n          shadow-md\n          cursor-default\n          sm:text-sm\n          border-gray-300\n        \"\n      >\n      </ComboboxInput>\n      <div class=\"\n          shadow-sm\n          flex\n          sm:text-sm\n          rounded-md\n          absolute\n          inset-y-0\n          right-10\n          items-center\n          pr-2\n          pointer-events-none\n        \"\n        >\n        {{ label }}\n      </div>\n        <SelectorIcon \n          class=\"\n            w-5 \n            h-5 \n            text-gray-400\n          \" \n          aria-hidden=\"true\"\n        />\n      </ComboboxButton>\n      <transition \n        leave-active-class=\"\n          transition \n          duration-100 \n          ease-in\n        \" \n        leave-from-class=\"\n          opacity-100\n        \"\n        leave-to-class=\"\n          opacity-0\n        \"\n      >\n        <ComboboxOptions\n          class=\"\n            -top-2 transform -translate-y-full\n            overflow-auto \n            absolute \n            z-10 \n            py-1 \n            mt-1 \n            w-full \n            max-h-60 \n            text-base \n            bg-white \n            rounded-md \n            ring-1 \n            ring-black \n            ring-opacity-5 \n            shadow-lg \n            focus:outline-none \n            sm:text-sm\n          \"\n        >\n          <div\n            v-if=\"filterOptions?.length === 0 && query !== ''\"\n            class=\"\n              cursor-default\n              select-none\n              relative\n              py-2\n              px-4\n              text-gray-700\n            \"\n          >\n            Nothing found.\n          </div>\n          <ComboboxOption \n            v-for=\"option in filterOptions\" \n            :key=\"option.title\" \n            v-slot=\"{ active, selected }\"\n            :value=\"option.id\" \n            as=\"template\"\n          >\n            <li :class=\"[\n              active ? 'bg-amber-100 text-amber-900' : 'text-gray-900',\n              'relative cursor-default select-none py-2 pl-10 pr-4',\n            ]\">\n              <span :class=\"[\n                selected ? 'font-medium' : 'font-normal',\n                'block truncate',\n              ]\">{{ option.title }}</span>\n              <span v-if=\"selected\" class=\"flex absolute inset-y-0 left-0 items-center pl-3 text-amber-600\">\n                <CheckIcon aria-hidden=\"true\" class=\"w-5 h-5\" />\n              </span>\n            </li>\n          </ComboboxOption>\n        </ComboboxOptions>\n      </transition>\n      <div class=\"text-xs text-red-400 mt-1\" v-if=\"error\">{{ error }}</div>\n    </div>\n  </Combobox>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, PropType, toRaw } from '@vue/runtime-core'\nimport {\n  Combobox,\n  ComboboxButton,\n  ComboboxInput,\n  ComboboxOption,\n  ComboboxOptions\n} from '@headlessui/vue'\nimport {CheckIcon, ChevronUpDownIcon as SelectorIcon} from \"@heroicons/vue/24/solid\"\n\nexport interface OptionObject {\n  id: number\n  title: string\n}\n\nexport default defineComponent({\n  name: \"PodloveCombobox\",\n  components: {\n    Combobox,\n    ComboboxButton,\n    ComboboxInput,\n    ComboboxOption,\n    ComboboxOptions,\n    CheckIcon,\n    SelectorIcon,\n  },\n\n  props: {\n    options: Array as PropType<OptionObject[]>,\n    selectValues: Array as PropType<Number[]>,\n    placeholder: {\n      type: String,\n      default: 'Select an option',\n    },\n    multiple: {\n      type: Boolean,\n      default: false,\n    },\n    error: String,\n  },\n\n  computed: {\n    label() : string | undefined {\n      const numOfSelect = this.selectValues?.length\n      if (numOfSelect === 0)\n        return \"No option is selected\"\n      else if (numOfSelect === 1)\n        return \"One option is selected\"\n      else \n        return numOfSelect?.toString() + \" options are selected\"\n    },\n\n    filterOptions() : Array<OptionObject> | undefined {\n      if (this.query === '')\n        return this.options\n      return this.options?.filter(option => {\n        return option.title.toLowerCase().includes(this.query.toLowerCase())\n      })\n    }\n  },\n\n  data() {\n    return {\n      query: ''\n    }\n  },\n\n  methods: {\n    selectItem(newSelectedItems: Array<Number>) {\n      this.$emit('update', newSelectedItems)\n    },\n    onChange(event: Event) {\n      this.query = (event.target as HTMLInputElement).value\n    },\n    resetQuery() {\n      this.query = ''\n    }\n  }\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/components/icons/Avatar.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z\" clip-rule=\"evenodd\"></path></svg>\n</template>\n"
  },
  {
    "path": "client/src/components/modal/Modal.vue",
    "content": "<!-- This example requires Tailwind CSS v2.0+ -->\n<template>\n  <TransitionRoot as=\"template\" :show=\"open\">\n    <Dialog as=\"div\" @close=\"close()\" data-client=\"podlove\">\n      <div class=\"fixed inset-0 overflow-y-auto font-sans\" style=\"z-index: 10000;\">\n        <div\n          class=\"\n            flex\n            items-end\n            justify-center\n            min-h-screen\n            pt-4\n            px-4\n            pb-20\n            text-center\n            sm:block sm:p-0\n          \"\n        >\n          <TransitionChild\n            as=\"template\"\n            enter=\"ease-out duration-300\"\n            enter-from=\"opacity-0\"\n            enter-to=\"opacity-100\"\n            leave=\"ease-in duration-200\"\n            leave-from=\"opacity-100\"\n            leave-to=\"opacity-0\"\n          >\n            <DialogOverlay class=\"fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity\" />\n          </TransitionChild>\n\n          <!-- This element is to trick the browser into centering the modal contents. -->\n          <span class=\"hidden sm:inline-block sm:align-middle sm:h-screen\" aria-hidden=\"true\"\n            >&#8203;</span\n          >\n          <TransitionChild\n            as=\"template\"\n            enter=\"ease-out duration-300\"\n            enter-from=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n            enter-to=\"opacity-100 translate-y-0 sm:scale-100\"\n            leave=\"ease-in duration-200\"\n            leave-from=\"opacity-100 translate-y-0 sm:scale-100\"\n            leave-to=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n          >\n            <div\n              class=\"\n                relative\n                inline-block\n                align-bottom\n                bg-white\n                rounded-lg\n                px-4\n                pt-5\n                pb-4\n                text-left\n                overflow-hidden\n                shadow-xl\n                transform\n                transition-all\n                sm:my-8 sm:align-middle sm:w-full sm:p-6\n              \"\n              :class=\"{\n                'sm:max-w-sm': size === 'small',\n                'sm:max-w-md': size === 'medium'\n              }\"\n            >\n              <div class=\"hidden sm:block absolute top-0 right-0 pt-4 pr-4\">\n                <button\n                  type=\"button\"\n                  class=\"\n                    bg-white\n                    rounded-md\n                    text-gray-400\n                    hover:text-gray-500\n                    focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\n                  \"\n                  @click=\"close()\"\n                >\n                  <span class=\"sr-only\">Close</span>\n                  <XIcon class=\"h-6 w-6\" aria-hidden=\"true\" />\n                </button>\n              </div>\n              <slot />\n            </div>\n          </TransitionChild>\n        </div>\n      </div>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<script lang=\"ts\">\nimport { Dialog, DialogOverlay, TransitionChild, TransitionRoot } from '@headlessui/vue'\nimport { defineComponent } from '@vue/runtime-core'\nimport { XMarkIcon as XIcon } from '@heroicons/vue/24/outline'\n\nexport default defineComponent({\n  components: {\n    Dialog,\n    DialogOverlay,\n    TransitionChild,\n    TransitionRoot,\n    XIcon,\n  },\n  props: {\n    open: {\n      type: Boolean,\n      default: true,\n    },\n    size: {\n      type: String,\n      default: 'small',\n    },\n  },\n  methods: {\n    close() {\n      this.$emit('close')\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/components/module/Module.vue",
    "content": "<template>\n  <section class=\"bg-white font-sans sm:rounded-lg sm:shadow-md\">\n    <div class=\"bg-white px-4 py-5 border-b border-gray-200 sm:px-6\">\n      <div class=\"-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap\">\n        <div class=\"ml-4 mt-2\">\n          <h3 class=\"text-lg leading-6 font-medium text-gray-900\" :for=\"`podlove-module-${name}`\">\n            {{ title }}\n          </h3>\n        </div>\n        <div class=\"ml-4 mt-2 flex flex-shrink-0\">\n          <slot name=\"actions\"></slot>\n        </div>\n      </div>\n    </div>\n    <div>\n      <slot></slot>\n    </div>\n  </section>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n  props: {\n    name: {\n      type: String,\n      default: null,\n    },\n    title: {\n      type: String,\n      default: null,\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/components/popover/Popover.vue",
    "content": "<template>\n  <Popover class=\"relative\">\n    <PopoverButton>\n      <slot name=\"trigger\" />\n    </PopoverButton>\n\n    <transition\n      enter-active-class=\"transition duration-200 ease-out\"\n      enter-from-class=\"translate-y-1 opacity-0\"\n      enter-to-class=\"translate-y-0 opacity-100\"\n      leave-active-class=\"transition duration-150 ease-in\"\n      leave-from-class=\"translate-y-0 opacity-100\"\n      leave-to-class=\"translate-y-1 opacity-0\"\n    >\n      <PopoverPanel class=\"absolute\">\n          <slot name=\"content\" class=\"overflow-hidden\"/>\n      </PopoverPanel>\n    </transition>\n  </Popover>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\nimport { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'\n\nexport default defineComponent({\n  components: {\n    Popover,\n    PopoverButton,\n    PopoverPanel,\n  }\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/components/steps/Steps.vue",
    "content": "<template>\n    <ol role=\"list\" class=\"divide-y divide-gray-300 rounded-md border border-gray-300 md:flex md:divide-y-0\">\n        <li v-for=\"(step, stepIdx) in steps\" :key=\"step.name\" class=\"relative md:flex md:flex-1\">\n            <p v-if=\"step.status === 'complete'\" class=\"group flex w-full items-center\">\n                <span class=\"flex items-center px-6 py-4 text-sm font-medium\">\n                    <span\n                        class=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 text-white\">\n                        <CheckIcon class=\"h-6 w-6 text-white\" aria-hidden=\"true\" />\n                    </span>\n                    <span class=\"ml-4 text-sm font-medium text-gray-900\">{{ step.name }}</span>\n                </span>\n            </p>\n            <p v-else-if=\"step.status === 'current'\" class=\"flex items-center px-6 py-4 text-sm font-medium\"\n                aria-current=\"step\">\n                <span\n                    class=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-indigo-600\">\n                    <span class=\"text-indigo-600\">{{ step.id }}</span>\n                </span>\n                <span class=\"ml-4 text-sm font-medium text-indigo-600\">{{ step.name }}</span>\n            </p>\n            <p v-else class=\"group flex items-center\">\n                <span class=\"flex items-center px-6 py-4 text-sm font-medium\">\n                    <span\n                        class=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-gray-300 group-hover:border-gray-400\">\n                        <span class=\"text-gray-500 group-hover:text-gray-900\">{{ step.id }}</span>\n                    </span>\n                    <span class=\"ml-4 text-sm font-medium text-gray-500 group-hover:text-gray-900\">{{ step.name\n                        }}</span>\n                </span>\n            </p>\n            <template v-if=\"stepIdx !== steps.length - 1\">\n                <!-- Arrow separator for lg screens and up -->\n                <div class=\"absolute right-0 top-0 hidden h-full w-5 md:block\" aria-hidden=\"true\">\n                    <svg class=\"h-full w-full text-gray-300\" viewBox=\"0 0 22 80\" fill=\"none\" preserveAspectRatio=\"none\">\n                        <path d=\"M0 -2L20 40L0 82\" vector-effect=\"non-scaling-stroke\" stroke=\"currentcolor\"\n                            stroke-linejoin=\"round\" />\n                    </svg>\n                </div>\n            </template>\n        </li>\n    </ol>\n</template>\n\n<script lang=\"ts\">\nimport { CheckIcon } from '@heroicons/vue/24/solid'\nimport { defineComponent, PropType } from 'vue'\n\nexport type StepStatus = 'complete' | 'current' | 'upcoming'\n\nexport interface Step {\n    id: Number,\n    name: string,\n    status: StepStatus\n}\n\nexport default defineComponent({\n    components: {\n        CheckIcon,\n    },\n    props: {\n        steps: {\n            type: Array as PropType<Step[]>,\n            default: [],\n        }\n    }\n})\n\n</script>"
  },
  {
    "path": "client/src/components/tabs/Tab.vue",
    "content": "<template>\n  <div class=\"hidden\" :data-tab=\"name\">\n    <slot></slot>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n  data() {\n    return {\n      visible: false\n    }\n  },\n\n  props: {\n    name: String\n  },\n\n  methods: {\n  }\n})\n</script>\n"
  },
  {
    "path": "client/src/components/tabs/TabsContainer.vue",
    "content": "<template>\n  <div>\n    <div class=\"block\">\n      <div class=\"border-b border-gray-200\">\n        <nav class=\"-mb-px flex space-x-8 mx-2\" aria-label=\"Tabs\">\n          <button\n            v-for=\"tab in tabs\"\n            @click=\"toggleTab(tab.name)\"\n            :key=\"tab.name\"\n            :class=\"[\n              tab.name === activeTab\n                ? 'border-indigo-500 text-indigo-600'\n                : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',\n              'group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm',\n            ]\"\n          >\n            <span>{{ tab.title }}</span>\n          </button>\n        </nav>\n      </div>\n    </div>\n    <div ref=\"tabs\">\n      <slot></slot>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { get } from 'lodash'\nimport { defineComponent } from 'vue'\n\nexport interface Tab {\n  title: string\n  name: string\n}\n\nexport default defineComponent({\n  data(): { activeTab: string; tabs: Tab[] } {\n    return {\n      activeTab: '',\n      tabs: [],\n    }\n  },\n\n  props: {\n    active: {\n      type: String,\n      default: null,\n    },\n  },\n\n  created() {\n    this.tabs =\n      this.$slots.default?.().map((elem) => ({\n        name: get(elem, ['props', 'name']),\n        title: get(elem, ['props', 'title']),\n      })) || []\n  },\n\n  mounted() {\n    if (this.active) {\n      this.toggleTab(this.active)\n    } else {\n      this.toggleTab(get(this.tabs, [0, 'name']))\n    }\n  },\n\n  methods: {\n    toggleTab(name: string) {\n      this.activeTab = name\n      ;(Array.from((this.$refs.tabs as HTMLElement).children) as HTMLElement[]).forEach(\n        (tab: HTMLElement) => {\n          if (tab.dataset.tab === name) {\n            tab.classList.remove('hidden')\n          } else {\n            tab.classList.add('hidden')\n          }\n        }\n      )\n    },\n  },\n})\n</script>\n\n<style>\n.tab-active {\n  text-shadow: 0px 0px 1px currentColor;\n}\n</style>\n"
  },
  {
    "path": "client/src/components/tabs/index.ts",
    "content": "// @ts-ignore\nimport Tab from './Tab.vue'\n// @ts-ignore\nimport TabsContainer from './TabsContainer.vue'\n\nexport {\n  Tab, TabsContainer\n}\n"
  },
  {
    "path": "client/src/components/tag/Tag.vue",
    "content": "<template>\n    <div class=\"m-1 inline-flex\">\n        <span class=\"\n            flex \n            items-center\n            text-xs\n            font-bold\n            uppercase\n            px-3\n            py-1\n            bg-indigo-200\n            text-indigo-700\n            rounded-full\n        \">\n            {{ value }}\n            <button class=\"ml-1\" @click=\"removeTag\"><XCircleIcon class=\"w-5 h-5\"/></button>\n        </span>\n    </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\nimport { XCircleIcon } from \"@heroicons/vue/24/solid\"\n\nexport default defineComponent({\n    name: 'PodloveTag',\n    components: {\n        XCircleIcon\n    },\n    props: {\n        value: String,\n        id: Number,\n    },\n    methods: {\n        removeTag() {\n            this.$emit('removeTag', this.id)\n        }\n    }\n});\n</script>\n\n<style>\n</style>"
  },
  {
    "path": "client/src/components/tooltip/Tooltip.vue",
    "content": "<template>\n  <div>\n    <Popper hover openDelay=\"200\" closeDelay=\"100\" :arrow=\"true\">\n      <slot name=\"trigger\" />\n\n      <template #content>\n        <div class=\"bg-white p-4 rounded shadow-lg max-w-md\">\n          <slot name=\"content\" />\n        </div>\n      </template>\n    </Popper>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, PropType } from '@vue/runtime-core'\nimport Popper from 'vue3-popper'\n\ntype TooltipDirection = 'top' | 'bottom' | 'left' | 'right'\n\nexport default defineComponent({\n  components: {\n    Popper,\n  },\n\n  props: {\n    direction: {\n      type: String as PropType<TooltipDirection>,\n      default: 'left',\n    },\n  },\n\n  data() {\n    return {\n      visible: true,\n    }\n  },\n\n  computed: {\n    caretClasses() {\n      switch (this.direction) {\n        case 'left':\n          return 'left-0 -ml-2 bottom-0 top-0 h-full'\n      }\n    },\n  },\n\n  methods: {\n    show() {\n      this.visible = true\n    },\n\n    hide() {\n      this.visible = false\n    },\n  },\n})\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/lib/api.ts",
    "content": "import { curry } from 'lodash'\n\nexport const addQuery = (url: string, query: { [key: string]: any } = {}) => {\n  const params = Object.keys(query)\n    .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(query[k]))\n    .join('&')\n\n  return (url += (url.indexOf('?') === -1 ? '?' : '&') + params)\n}\n\nexport const responseParser =\n  (errorHandler: Function = console.error) =>\n  async (response: Response) => {\n    let result\n\n    try {\n      result = await response.json()\n    } catch (err) {\n      result = {}\n    }\n\n    if (response.status >= 300) {\n      errorHandler(result)\n      return {\n        error: result,\n      }\n    }\n\n    return {\n      result,\n    }\n  }\n\nexport interface ApiOptions {\n  headers?: { [key: string]: string }\n  query?: { [key: string]: string }\n  limit?: number\n  minimal_data?: boolean\n  hooks?: {\n    onUploadProgress?: any\n  }\n}\n\nconst defaultHeaders = (\n  { nonce, auth, bearer }: { nonce?: string; auth?: string; bearer?: string },\n  headers: { [key: string]: string } = {}\n) => ({\n  'Content-Type': 'application/json',\n  Accept: 'application/json',\n  ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),\n  ...(auth ? { Authorization: `Basic ${auth}` } : {}),\n  ...(nonce ? { 'X-WP-Nonce': nonce } : {}),\n  ...headers,\n})\n\nconst readApi =\n  ({\n    errorHandler,\n    nonce,\n    auth,\n    bearer,\n    method,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    nonce?: string\n    auth?: string\n    bearer?: string\n    method: 'GET' | 'DELETE'\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, { headers, query }: ApiOptions = {}) =>\n    fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), {\n      method,\n      headers: defaultHeaders({ nonce, auth, bearer }, headers),\n    }).then(responseParser(errorHandler))\n\nconst createApi =\n  ({\n    errorHandler,\n    nonce,\n    auth,\n    bearer,\n    method,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    nonce?: string\n    auth?: string\n    bearer?: string\n    method: 'POST' | 'PUT'\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, data: any, { headers, query }: ApiOptions = {}) => {\n    return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), {\n      method,\n      headers: defaultHeaders({ nonce, auth, bearer }, headers),\n      body: JSON.stringify(data),\n    }).then(responseParser(errorHandler))\n  }\n\nexport interface PodloveApiClient {\n  get: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  delete: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  post: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  put: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }>\n}\n\nexport const podlove = curry(\n  ({\n    base,\n    version,\n    nonce,\n    auth,\n    bearer,\n    errorHandler,\n  }: {\n    errorHandler: Function\n    base: string\n    version: string\n    nonce?: string\n    auth?: string\n    bearer?: string\n  }) => ({\n    get: readApi({\n      nonce,\n      auth,\n      bearer,\n      method: 'GET',\n      errorHandler,\n      urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`,\n    }),\n\n    delete: readApi({\n      nonce,\n      auth,\n      bearer,\n      errorHandler,\n      method: 'DELETE',\n      urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`,\n    }),\n\n    post: createApi({\n      nonce,\n      auth,\n      bearer,\n      errorHandler,\n      method: 'POST',\n      urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`,\n    }),\n\n    put: createApi({\n      nonce,\n      auth,\n      bearer,\n      errorHandler,\n      method: 'PUT',\n      urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`,\n    }),\n  })\n)\n"
  },
  {
    "path": "client/src/lib/array.ts",
    "content": "export const arrayMove = <T>(arr: T[], fromIndex: number, toIndex: number) => {\n  const newArr = [...arr];\n  newArr.splice(toIndex, 0, newArr.splice(fromIndex, 1)[0]);\n  return newArr;\n};\n"
  },
  {
    "path": "client/src/lib/auphonic.api.ts",
    "content": "import { curry } from 'lodash'\nimport axios, { AxiosProgressEvent } from 'axios'\nimport { addQuery, responseParser, ApiOptions } from './api'\n\n// TODO: replace fetch with axios\n\nconst defaultHeaders = (\n  { bearer }: { bearer?: string },\n  headers: { [key: string]: string } = {}\n) => ({\n  'Content-Type': 'application/json',\n  Accept: 'application/json',\n  ...authHeaders({ bearer }),\n  ...headers,\n})\n\nconst authHeaders = ({ bearer }: { bearer?: string }) => ({\n  ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),\n})\n\nconst readApi =\n  ({\n    errorHandler,\n    bearer,\n    method,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    bearer?: string\n    method: 'GET' | 'DELETE'\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, { headers, query }: ApiOptions = {}) =>\n    fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), {\n      method,\n      headers: defaultHeaders({ bearer }, headers),\n    }).then(responseParser(errorHandler))\n\nconst createApi =\n  ({\n    errorHandler,\n    bearer,\n    method,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    bearer?: string\n    method: 'POST' | 'PUT'\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, data: any, { headers, query }: ApiOptions = {}) => {\n    return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), {\n      method,\n      headers: defaultHeaders({ bearer }, headers),\n      body: JSON.stringify(data),\n    }).then(responseParser(errorHandler))\n  }\n\nconst deleteApi =\n  ({\n    errorHandler,\n    bearer,\n    method,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    bearer?: string\n    method: 'DELETE'\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, data: any, { headers, query }: ApiOptions = {}) => {\n    return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), {\n      method,\n      headers: defaultHeaders({ bearer }, headers),\n      body: JSON.stringify(data),\n    }).then(responseParser(errorHandler))\n  }\n\nfunction defaultProgressHandler(e: AxiosProgressEvent) {\n  console.log('default progress', e)\n}\n\nconst uploadApi =\n  ({\n    errorHandler,\n    bearer,\n    urlProcessor,\n  }: {\n    errorHandler?: Function\n    bearer?: string\n    urlProcessor?: (url: string) => string\n  }) =>\n  (url: string, data: any, { query, hooks }: ApiOptions = {}) => {\n    const formData = new FormData()\n    const onUploadProgress = hooks?.onUploadProgress || defaultProgressHandler\n\n    // audio file upload\n    if (data.file) {\n      // track id for multitrack, 'input_file' for single track\n      const id = data.track_id || 'input_file'\n      formData.append(id, data.file)\n    }\n\n    // cover poster upload\n    if (data.image) {\n      formData.append('image', data.image)\n    }\n\n    return axios.post(addQuery(urlProcessor ? urlProcessor(url) : url, query), formData, {\n      headers: {\n        ...authHeaders({ bearer }),\n      },\n      onUploadProgress: onUploadProgress,\n    })\n  }\n\nexport interface AuphonicApiClient {\n  get: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  post: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  delete: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }>\n  upload: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }>\n}\n\nexport const auphonic = curry(\n  ({ base, bearer, errorHandler }: { base: string; bearer?: string; errorHandler: Function }) => ({\n    get: readApi({\n      bearer,\n      method: 'GET',\n      errorHandler,\n      urlProcessor: (endpoint) => `${base}/${endpoint}`,\n    }),\n    post: createApi({\n      bearer,\n      errorHandler,\n      method: 'POST',\n      urlProcessor: (endpoint) => `${base}/${endpoint}`,\n    }),\n    delete: deleteApi({\n      bearer,\n      errorHandler,\n      method: 'DELETE',\n      urlProcessor: (endpoint) => `${base}/${endpoint}`,\n    }),\n    upload: uploadApi({\n      bearer,\n      errorHandler,\n      urlProcessor: (endpoint) => `${base}/${endpoint}`,\n    }),\n  })\n)\n"
  },
  {
    "path": "client/src/lib/chapters.ts",
    "content": "import * as npt from './normalplaytime'\nimport { PodloveChapter } from '../types/chapters.types'\n\nexport function parseMp4Chapters(input: string): PodloveChapter[] {\n  const pattern = /^([\\d\\.:]+)\\s(.*)$/\n\n  return input\n    .trim()\n    .split(/(\\r?\\n)/)\n    .reduce(function (all: PodloveChapter[], chapter: string) {\n      var matches = chapter.match(pattern)\n\n      if (matches) {\n        var time = npt.parse(matches[1])\n\n        if (time !== null) {\n          all.push({\n            title: matches[2].trim(),\n            start: time,\n          })\n        }\n      }\n\n      return all\n    }, [])\n}\n\nexport function parseAudacityChapters(input: string): PodloveChapter[] {\n  const pattern = /^([\\d\\.,]+)\\s+([\\d\\.,]+)\\s+(.*)$/\n  return input\n    .trim()\n    .split(/(\\r\\n|\\r|\\n)/)\n    .reduce(function (all: PodloveChapter[], chapter: string) {\n      var matches = chapter.match(pattern)\n\n      if (matches) {\n        var time = npt.parse(matches[1].replace(',', '.'))\n        var title = matches[3].trim()\n\n        if (time !== null) {\n          all.push({\n            title: title,\n            start: time,\n          })\n        }\n      }\n\n      return all\n    }, [])\n}\n\nexport function parseHindeburgChapters(input: string) {\n  const parser = new window.DOMParser()\n  const xml = parser.parseFromString(input, 'text/xml')\n  const chapterTags = xml.getElementsByTagName('Marker')\n\n  let chapters = Array.from(chapterTags).reduce((result: PodloveChapter[], tag) => {\n    if (\n      !tag ||\n      !tag.getAttribute('Type') ||\n      tag.getAttribute('Type')?.toLowerCase() !== 'chapter'\n    ) {\n      return result\n    }\n\n    const start = npt.parse(tag.getAttribute('Time') || '')\n    const title = tag.getAttribute('Name') || ''\n    const href = tag.getAttribute('URL') || ''\n\n    if (start !== null) {\n      result.push({ start: start, title: title.trim(), ...(href ? { href: href.trim() } : {}) })\n    }\n\n    return result\n  }, [])\n\n  chapters.sort(function (chapterA, chapterB) {\n    return chapterA.start - chapterB.start\n  })\n\n  return chapters\n}\n\nexport function parsePodloveChapters(input: string): PodloveChapter[] {\n  const parser = new window.DOMParser()\n  const xml = parser.parseFromString(input, 'text/xml')\n  const chapterTags = xml.getElementsByTagNameNS('http://podlove.org/simple-chapters', 'chapter')\n\n  return Array.from(chapterTags).reduce((result: PodloveChapter[], tag) => {\n    var start = npt.parse(tag.getAttribute('start') || '')\n    var title = tag.getAttribute('title') || ''\n    var href = tag.getAttribute('href') || ''\n    var image = tag.getAttribute('image') || ''\n\n    if (start !== null) {\n      result.push({\n        start: start,\n        title: title.trim(),\n        ...(href ? { href: href.trim() } : {}),\n        ...(image ? { image: image.trim() } : {}),\n      })\n    }\n\n    return result\n  }, [])\n}\n"
  },
  {
    "path": "client/src/lib/errorHandling.ts",
    "content": "/**\n * Common error handling utilities for file processing operations\n */\n\nexport interface FileWithUrl {\n  filename?: string\n  name?: string\n  download_url?: string\n  localUrl?: string\n}\n\nexport interface ErrorResponse {\n  success: false\n  status: 'failed'\n  filename: string\n  download_url: string\n  message: string\n}\n\n/**\n * Creates a standardized error response for failed file operations\n */\nexport const createErrorResponse = (file: FileWithUrl, error: any): ErrorResponse => ({\n  success: false,\n  status: 'failed',\n  filename: file.filename || file.name || 'unknown',\n  download_url: file.download_url || file.localUrl || 'unknown',\n  message: error.message || 'Processing failed'\n})\n\n/**\n * Extracts error message from API response with fallback\n */\nexport const getApiErrorMessage = (response: any, fallback: string = 'Request failed'): string => {\n  return response.error?.message ||\n         response.message ||\n         response.result?.message ||\n         fallback\n}\n\n/**\n * Creates a transfer failed error response with descriptive message\n */\nexport const createTransferErrorResponse = (file: FileWithUrl, errorMessage: string): ErrorResponse => ({\n  success: false,\n  status: 'failed',\n  filename: file.filename || file.name || 'unknown',\n  download_url: file.download_url || file.localUrl || 'unknown',\n  message: `Transfer failed: ${errorMessage}`\n})\n"
  },
  {
    "path": "client/src/lib/license.ts",
    "content": "import {\n  PodloveLicense,\n  PodloveLicenseOptionCommercial,\n  PodloveLicenseOptionModification,\n  PodloveLicenseVersion,\n  PodloveLicenseOptionJurisdication,\n} from '../types/license.types'\n\nimport pdImage from '../assets/pd.png'\nimport pdMarkImage from '../assets/pdmark.png'\nimport cc_0_0_Image from '../assets/0_0.png'\nimport cc_0_1_Image from '../assets/0_1.png'\nimport cc_1_0_Image from '../assets/1_0.png'\nimport cc_1_1_Image from '../assets/1_1.png'\nimport cc_10_0_Image from '../assets/10_0.png'\nimport cc_10_1_Image from '../assets/10_1.png'\n\nexport function getLicenseUrl(input: PodloveLicense): string | null {\n  if (input.type === null || input.type != 'cc') return null\n  if (input.version === PodloveLicenseVersion.cc0)\n    return 'http://creativecommons.org/publicdomain/zero/1.0/'\n  if (input.version === PodloveLicenseVersion.pdmark)\n    return 'http://creativecommons.org/publicdomain/mark/1.0/'\n  if (input.version === PodloveLicenseVersion.cc3) {\n    let cc3 = 'http://creativecommons.org/licenses/by'\n    if (input.optionCommercial === PodloveLicenseOptionCommercial.no) cc3 = cc3 + '-nc'\n    if (input.optionModification === PodloveLicenseOptionModification.yes) cc3 = cc3 + '/'\n    else if (input.optionModification === PodloveLicenseOptionModification.no) cc3 = cc3 + '-nd/'\n    else cc3 = cc3 + '-sa/'\n    if (input.optionJurisdication === null || input.optionJurisdication.symbol === 'international')\n      cc3 = cc3 + '3.0/'\n    else\n      cc3 = cc3 + input.optionJurisdication.version + '/' + input.optionJurisdication.symbol + '/'\n    return cc3 + 'deed.en'\n  }\n  if (input.version === PodloveLicenseVersion.cc4) {\n    let cc4 = 'http://creativecommons.org/licenses/by'\n    if (input.optionCommercial == PodloveLicenseOptionCommercial.no) cc4 = cc4 + '-nc'\n    if (input.optionModification == PodloveLicenseOptionModification.yes) cc4 = cc4 + '/'\n    else if (input.optionModification == PodloveLicenseOptionModification.no) cc4 = cc4 + '-nd/'\n    else cc4 = cc4 + '-sa/'\n    return cc4 + '4.0'\n  }\n  return null\n}\n\nexport function getImageUrl(input: PodloveLicense, baseUrl: string): string | null {\n  if (input.type === null || input.type !== 'cc') return null\n  if (input.version === PodloveLicenseVersion.cc0)\n    return pdImage;\n  if (input.version === PodloveLicenseVersion.pdmark)\n    return pdMarkImage;\n  if (input.optionModification === null || input.optionCommercial === null) return null\n  switch(input.optionModification) {\n    case PodloveLicenseOptionModification.no:\n      return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_0_0_Image : cc_0_1_Image\n    case PodloveLicenseOptionModification.yes:\n      return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_1_0_Image : cc_1_1_Image\n    case PodloveLicenseOptionModification.yesbutshare:\n      return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_10_0_Image : cc_10_1_Image\n  }\n}\n\nexport function getLicenseFromUrl(url: string): PodloveLicense {\n  const urlLowerCase = url.toLowerCase()\n  // only parse cc licenses\n  if (urlLowerCase.indexOf('creativecommons.org') < 0) {\n    return {\n      type: null,\n      version: null,\n      optionCommercial: null,\n      optionModification: null,\n      optionJurisdication: null,\n    } as PodloveLicense\n  }\n\n  let version: PodloveLicenseVersion | null = null\n\n  if (urlLowerCase.indexOf('/publicdomain/zero/') >= 0) {\n    version = PodloveLicenseVersion.cc0\n  } else {\n    if (urlLowerCase.indexOf('/publicdomain/mark/') >= 0) {\n      version = PodloveLicenseVersion.pdmark\n    } else {\n      if (urlLowerCase.indexOf('/4.0') >= 0) {\n        version = PodloveLicenseVersion.cc4\n      } else {\n        version = PodloveLicenseVersion.cc3\n      }\n    }\n  }\n\n  const urlData = urlLowerCase.split('/').slice(4)\n\n  let commercial: PodloveLicenseOptionCommercial = PodloveLicenseOptionCommercial.yes\n  if (urlData[0].includes('nc')) {\n    commercial = PodloveLicenseOptionCommercial.no\n  }\n\n  let modification: PodloveLicenseOptionModification = PodloveLicenseOptionModification.yes\n  if (urlData[0].includes('sa')) {\n    modification = PodloveLicenseOptionModification.yesbutshare\n  } else {\n    if (urlData[0].includes('nd')) {\n      modification = PodloveLicenseOptionModification.no\n    }\n  }\n\n  let jurisdication = PodloveLicenseOptionJurisdication[0]\n  if (urlData.length > 2) {\n    const idx: number = PodloveLicenseOptionJurisdication.findIndex(\n      (item) => item.symbol === urlData[2]\n    )\n    if (idx > 0) jurisdication = PodloveLicenseOptionJurisdication[idx]\n  }\n\n  return {\n    type: 'cc',\n    version: version,\n    optionCommercial: commercial,\n    optionModification: modification,\n    optionJurisdication: jurisdication,\n  } as PodloveLicense\n}\n"
  },
  {
    "path": "client/src/lib/normalplaytime.ts",
    "content": "const parse_ms_string = function (msstring: string) {\n  if (!msstring) {\n    return 0\n  }\n\n  switch (msstring.length) {\n    case 0:\n      return 0\n      break\n    case 1:\n      return parseInt(msstring, 10) * 100\n      break\n    case 2:\n      return parseInt(msstring, 10) * 10\n      break\n    default:\n      return parseInt(msstring.substr(0, 3), 10)\n      break\n  }\n}\n\nexport const parse = function (timestring: string): number | null {\n  timestring = (timestring || '').trim()\n\n  const pattern_seconds = /^(\\d+)(?:\\.(\\d+))?$/\n  const pattern_minutes = /^(\\d+):(\\d\\d?)(?:\\.(\\d+))?$/\n  const pattern_hours = /^(\\d+):(\\d\\d?):(\\d\\d?)(?:\\.(\\d+))?$/\n\n  let matches\n  let ms = 0\n  let sec = 0\n  let min = 0\n  let hr = 0\n\n  if ((matches = timestring.match(pattern_seconds))) {\n    ms = parse_ms_string(matches[2])\n    sec = matches[1] ? parseInt(matches[1], 10) : 0\n  } else if ((matches = timestring.match(pattern_minutes))) {\n    ms = parse_ms_string(matches[3])\n    sec = matches[2] ? parseInt(matches[2], 10) : 0\n    min = matches[1] ? parseInt(matches[1], 10) : 0\n  } else if ((matches = timestring.match(pattern_hours))) {\n    ms = parse_ms_string(matches[4])\n    sec = matches[3] ? parseInt(matches[3], 10) : 0\n    min = matches[2] ? parseInt(matches[2], 10) : 0\n    hr = matches[1] ? parseInt(matches[1], 10) : 0\n  } else {\n    return null\n  }\n\n  return ((hr * 60 + min) * 60 + sec) * 1000 + ms\n}\n"
  },
  {
    "path": "client/src/lib/popper.ts",
    "content": "import { ref, onMounted, watchEffect } from \"vue\";\nimport { createPopper, Options } from \"@popperjs/core\";\n\nexport function usePopper(options: Partial<Options>) {\n  let reference = ref<{ el: HTMLElement } | null>(null);\n  let popper = ref<{ el: HTMLElement } | null>(null);\n\n  onMounted(() => {\n    watchEffect((onInvalidate) => {\n      if (!popper.value) return;\n      if (!reference.value) return;\n\n      let popperEl = popper.value.el || popper.value;\n      let referenceEl = reference.value.el || reference.value;\n\n      if (!(referenceEl instanceof HTMLElement)) return;\n      if (!(popperEl instanceof HTMLElement)) return;\n\n      let { destroy } = createPopper(referenceEl, popperEl, options);\n\n      onInvalidate(destroy);\n    });\n  });\n\n  return [reference, popper];\n}\n"
  },
  {
    "path": "client/src/lib/statusHelpers.ts",
    "content": "/**\n * Common status determination utilities for file processing operations\n */\n\nexport type ProcessingStatus = 'completed' | 'completed_with_errors' | 'failed'\nexport type MigrationStatus = 'finished' | 'error'\n\n/**\n * Determines the final transfer status based on success/failure counts\n */\nexport const determineTransferStatus = (\n  hasErrors: boolean,\n  successCount: number\n): ProcessingStatus => {\n  if (!hasErrors) return 'completed'\n  if (successCount > 0) return 'completed_with_errors'\n  return 'failed'\n}\n\n/**\n * Determines the final migration status based on error state\n */\nexport const determineMigrationStatus = (hasErrors: boolean): MigrationStatus => {\n  return hasErrors ? 'error' : 'finished'\n}\n\n/**\n * Checks if a processing result indicates success\n */\nexport const isSuccessResult = (result: any): boolean => {\n  if (typeof result === 'boolean') return result\n  if (result && typeof result === 'object') {\n    return result.success === true // only true is success, null (pending) and false are not\n  }\n  return false\n}\n\n/**\n * Counts successful results from an array of processing results\n */\nexport const countSuccessfulResults = (results: any[]): number => {\n  return results.filter(isSuccessResult).length\n}\n"
  },
  {
    "path": "client/src/lib/timestamp.ts",
    "content": "import * as npt from './normalplaytime'\n\nexport default class Timestamp {\n  constructor(public totalMs: number) {}\n\n  get totalSeconds() {\n    return Math.floor(this.totalMs / 1000)\n  }\n\n  get totalMinutes() {\n    return Math.floor(this.totalSeconds / 60)\n  }\n\n  get totalHours() {\n    return Math.floor(this.totalMinutes / 60)\n  }\n\n  get milliseconds() {\n    return this.totalMs % 1000\n  }\n\n  get seconds() {\n    return this.totalSeconds % 60\n  }\n\n  get minutes() {\n    return this.totalMinutes % 60\n  }\n\n  get hours() {\n    return this.totalHours % 60\n  }\n\n  get pretty() {\n    return (\n      this.pad(this.totalHours) +\n      ':' +\n      this.pad(this.minutes) +\n      ':' +\n      this.pad(this.seconds) +\n      '.' +\n      this.pad(this.milliseconds, '000')\n    )\n  }\n\n  get prettyShort() {\n    if (this.totalHours) {\n      return this.pad(this.totalHours) + ':' + this.pad(this.minutes) + ':' + this.pad(this.seconds)\n    } else {\n      return this.pad(this.minutes) + ':' + this.pad(this.seconds)\n    }\n  }\n\n  pad(num: number, pad = '00') {\n    let str = '' + num\n\n    if (str.length < pad.length) {\n      return pad.substring(0, pad.length - str.length) + str\n    } else {\n      return num\n    }\n  }\n\n  static fromString(t: string | number) {\n    let ms = 0\n\n    if (t == parseInt(t as string, 10)) {\n      ms = parseInt(t as string, 10)\n    } else {\n      ms = npt.parse(t as string) || 0\n    }\n\n    return new Timestamp(ms)\n  }\n}\n"
  },
  {
    "path": "client/src/lib/wordpress.ts",
    "content": "import { get } from 'lodash'\n\nexport const store = get(window, ['wp', 'data'], null)\nexport const media = get(window, ['wp', 'media'], null)\nexport const postTitleInput: HTMLInputElement | null = document.querySelector('input[name=\"post_title\"]')\nexport const postTitleListener = (cb: (title: string) => any) => postTitleInput?.addEventListener('change', event => cb(get(event, ['target', 'value'])))\n"
  },
  {
    "path": "client/src/modules/auphonic/Auphonic.vue",
    "content": "<template>\n  <module name=\"auphonic\" title=\"Auphonic\">\n    <div v-if=\"!isProductionSelected\">\n      <StartScreen />\n    </div>\n\n    <div class=\"m-7\" v-if=\"productionId\">\n      <ManageProductionForm />\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport * as auphonic from '@store/auphonic.store'\nimport ManageProductionForm from './components/ManageProductionForm.vue'\nimport StartScreen from './components/StartScreen.vue'\nimport AuphonicLogo from './components/Logo.vue'\nimport Module from '@components/module/Module.vue'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    ManageProductionForm,\n    StartScreen,\n    AuphonicLogo,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        productionId: selectors.auphonic.productionId,\n        isInitializing: selectors.auphonic.isInitializing,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  computed: {\n    productionId(): string {\n      return this.state.productionId || ''\n    },\n    isInitializing(): boolean {\n      return this.state.isInitializing\n    },\n    isProductionSelected(): boolean {\n      return !!this.productionId\n    },\n  },\n\n  created() {\n    this.dispatch(auphonic.init())\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/FileChooser.vue",
    "content": "<template>\n  <div>\n    <div class=\"flex flex-col gap-2\">\n      <!-- step one -->\n      <div>\n        <label class=\"block md:hidden text-sm font-medium text-gray-700\">Upload Method</label>\n        <select\n          @change=\"handleServiceSelection\"\n          class=\"mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md\"\n        >\n          <option\n            v-for=\"service in services\"\n            :key=\"service.uuid\"\n            :value=\"service.uuid\"\n            :selected=\"service.uuid == currentServiceSelection\"\n          >\n            {{ service.type }}: {{ service.display_name }}\n          </option>\n        </select>\n      </div>\n\n      <!-- step two -->\n      <div v-if=\"currentService\">\n        <div v-if=\"currentService.type === 'file'\">\n          <label\n            :for=\"file_key + 'file-upload'\"\n            class=\"relative flex items-center gap-2 cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none\"\n          >\n            <div\n              class=\"sm:mt-1 rounded-md bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100\"\n            >\n              {{ __('Choose File', 'podlove-podcasting-plugin-for-wordpress') }}\n            </div>\n\n            <div class=\"sm:mt-1 text-sm font-normal\" v-if=\"filenameSelectedForUpload\">\n              {{ __('Selected for Upload:', 'podlove-podcasting-plugin-for-wordpress') }}\n              <span>{{ filenameSelectedForUpload }}</span>\n            </div>\n\n            <input\n              :id=\"file_key + 'file-upload'\"\n              name=\"file-upload\"\n              type=\"file\"\n              class=\"sr-only\"\n              @input=\"handleFileUploadSelection\"\n            />\n          </label>\n        </div>\n        <div v-else-if=\"currentService.type === 'url'\">\n          <label\n            :for=\"file_key + 'audio_source_url'\"\n            class=\"block text-sm font-medium text-gray-700\"\n            >File URL</label\n          >\n          <div class=\"mt-1\">\n            <input\n              type=\"url\"\n              name=\"audio_source_url\"\n              :id=\"file_key + 'audio_source_url'\"\n              class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n              placeholder=\"https://example.com/audio.flac\"\n              :value=\"urlFieldValue\"\n              @input=\"handleUrlUpdate\"\n            />\n          </div>\n        </div>\n        <div v-else>\n          <div v-if=\"serviceFiles !== null\">\n            <label :for=\"file_key + 'external_file'\" class=\"block text-sm font-medium text-gray-700\"\n              >File</label\n            >\n            <select\n              name=\"audio_external_file\"\n              :id=\"file_key + 'external_file'\"\n              class=\"mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md\"\n              :value=\"externalFileFieldValue\"\n              @change=\"handleFileSelection\"\n            >\n              <option v-for=\"file in serviceFiles\" :key=\"file\" :value=\"file\" d>\n                {{ file }}\n              </option>\n            </select>\n          </div>\n          <div v-else>...</div>\n        </div>\n        <div v-if=\"shouldSuggestSlug\" class=\"mt-3\">\n          <button\n            @click=\"() => setEpisodeSlug(slugCandidate)\"\n            type=\"button\"\n            class=\"relative text-xs inline-flex items-center rounded-md bg-white px-3 py-2 font-medium text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\"\n          >\n            <DocumentCheckIcon class=\"-ml-0.5 mr-1.5 h-4 w-4 text-gray-400\" aria-hidden=\"true\" />\n            <span\n              >{{ __('Use as Episode Slug:', 'podlove-podcasting-plugin-for-wordpress') }}\n              <span class=\"font-mono\">{{ slugCandidate }}</span></span\n            >\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\n\nimport * as auphonic from '@store/auphonic.store'\nimport { FileSelection, Service } from '@store/auphonic.store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { disableSlugAutogen } from '@store/mediafiles.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport {\n  Listbox,\n  ListboxButton,\n  ListboxLabel,\n  ListboxOption,\n  ListboxOptions,\n} from '@headlessui/vue'\nimport { CheckIcon, ChevronUpDownIcon as SelectorIcon } from '@heroicons/vue/24/solid'\nimport { get } from 'lodash'\n\nimport { DocumentCheckIcon } from '@heroicons/vue/24/outline'\n\ntype LocalFileSelection = {\n  urlValue?: string | null\n  fileValue?: File | string | null\n  currentServiceSelection?: string | null\n  fileSelection?: string | null\n}\n\ntype ServiceFilesMap = Record<string, string[] | null>\n\nexport default defineComponent({\n  props: ['file_key', 'track_index'],\n\n  components: {\n    Listbox,\n    ListboxButton,\n    ListboxLabel,\n    ListboxOption,\n    ListboxOptions,\n    CheckIcon,\n    SelectorIcon,\n    DocumentCheckIcon,\n  },\n\n  setup() {\n    const state = mapAppState({\n      services: selectors.auphonic.incomingServices,\n      serviceFiles: selectors.auphonic.serviceFiles,\n      fileSelections: selectors.auphonic.fileSelections,\n      episodeSlug: selectors.episode.slug,\n    })\n\n    return {\n      state,\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    set(prop: string, value: string | File | null) {\n      this.dispatch(\n        auphonic.updateFileSelection({\n          key: this.file_key,\n          prop,\n          value,\n        })\n      )\n\n      if (Number.isInteger(this.track_index)) {\n        // mark track as modified\n        this.dispatch(\n          auphonic.updateTrack({\n            track: {},\n            index: this.track_index,\n          })\n        )\n      }\n    },\n    handleFileSelection(event: Event): void {\n      this.set('fileSelection', (event.target as HTMLSelectElement).value)\n    },\n    handleUrlUpdate(event: Event): void {\n      this.set('urlValue', (event.target as HTMLInputElement).value)\n    },\n    handleFileUploadSelection(event: Event): void {\n      const files = (event.target as HTMLInputElement).files\n      if (files) {\n        this.set('fileValue', files[0])\n      } else {\n        this.set('fileValue', null)\n      }\n    },\n    handleServiceSelection(event: Event): void {\n      this.set('currentServiceSelection', (event.target as HTMLSelectElement).value)\n    },\n    setEpisodeSlug(slug: String | null) {\n      this.dispatch(updateEpisode({ prop: 'slug', value: slug }))\n      this.dispatch(disableSlugAutogen())\n    },\n  },\n\n  computed: {\n    services(): Service[] {\n      return this.state.services\n    },\n    currentService(): Service | null {\n      return this.state.services.find((s: Service) => s.uuid === this.currentServiceSelection) || null\n    },\n    serviceFilesMap(): ServiceFilesMap {\n      return this.state.serviceFiles as ServiceFilesMap\n    },\n    serviceFiles(): string[] | null {\n      return this.currentServiceSelection\n        ? this.serviceFilesMap[this.currentServiceSelection] ?? null\n        : null\n    },\n    fileSelection(): LocalFileSelection {\n      return get(this.state.fileSelections, [this.file_key], {}) as LocalFileSelection\n    },\n    filenameSelectedForUpload(): string | null {\n      const fileValue = this.fileSelection.fileValue\n      return fileValue instanceof File ? fileValue.name : null\n    },\n    currentServiceSelection(): string | null {\n      return this.fileSelection.currentServiceSelection || null\n    },\n    urlFieldValue(): string {\n      return this.fileSelection.urlValue || ''\n    },\n    externalFileFieldValue(): string {\n      return this.fileSelection.fileSelection || ''\n    },\n    slugCandidate(): string | null {\n      if (this.currentServiceSelection == 'url') {\n        if (this.urlFieldValue) {\n          return this.urlFieldValue.split('/').reverse()[0].split('.')[0]\n        }\n      }\n\n      if (this.currentServiceSelection == 'file') {\n        const filename = this.filenameSelectedForUpload\n        return filename ? filename.split('.')[0] : null\n      }\n\n      return null\n    },\n    shouldSuggestSlug(): boolean {\n      return this.slugCandidate != null && this.slugCandidate != this.state.episodeSlug\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/Logo.vue",
    "content": "<template>\n  <svg\n    :class=\"className\"\n    viewBox=\"0 0 301.24005 225.44299\"\n    stroke=\"currentColor\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <g transform=\"translate(-12.73496,-48.464)\">\n      <path\n        d=\"m 28.623,223.469 c -3.277,-0.007 -7.473,0.836 -12.588,2.528 l 1.46,6.491 c 3.476,-1.302 6.689,-1.95 9.638,-1.944 3.472,0.008 5.797,0.63 6.975,1.868 1.176,1.237 1.762,3.569 1.754,6.998 l -7.373,-0.017 c -5.047,-0.011 -8.933,1.031 -11.655,3.125 -2.725,2.092 -4.091,5.007 -4.099,8.739 -0.008,3.334 1.198,6.07 3.618,8.207 2.419,2.14 5.661,3.212 9.726,3.221 4.389,0.01 8.063,-1.161 11.017,-3.512 l 1.075,2.655 7.471,0.017 0.052,-23.199 c 0.011,-4.848 -1.243,-8.587 -3.759,-11.214 -2.519,-2.628 -6.956,-3.949 -13.312,-3.963 z m 7.6,30 c -0.33,0.195 -0.92,0.505 -1.772,0.929 -0.853,0.424 -1.738,0.734 -2.656,0.929 -0.92,0.193 -2.033,0.29 -3.343,0.287 -1.966,-0.004 -3.506,-0.443 -4.618,-1.314 -1.113,-0.873 -1.666,-2.08 -1.663,-3.618 0.007,-3.079 2.467,-4.611 7.382,-4.601 4.916,0.011 6.685,0.006 6.685,0.006 l -0.015,7.382 z m 41.315,-29.3 9.241,0.021 -0.084,37.845 -6.489,-0.014 -1.173,-2.754 c -2.494,1.173 -5.743,3.524 -12.295,3.51 -5.701,-0.013 -13.761,-0.336 -13.723,-17.135 l 0.048,-21.527 9.339,0.021 -0.051,22.806 c -0.013,5.661 2.719,7.772 6.863,7.781 2.883,0.007 5.638,-0.74 8.262,-2.243 l 0.062,-28.311 z m 44.305,1.377 c -2.751,-1.251 -5.502,-1.88 -8.254,-1.887 -3.801,-0.008 -7.539,1 -11.212,3.023 l -1.076,-2.46 -6.586,-0.015 -0.111,49.679 9.436,0.021 0.029,-13.013 c 1.505,0.659 4.534,1.975 8.154,1.983 6.096,0.014 10.833,-1.728 14.215,-5.226 3.385,-3.498 5.083,-8.296 5.097,-14.391 0.011,-4.719 -0.914,-8.538 -2.776,-11.458 -1.863,-2.918 -4.166,-5.005 -6.916,-6.256 z m -3.061,27.173 c -2.066,1.863 -4.772,2.792 -8.114,2.784 -2.425,-0.005 -4.587,-0.633 -6.484,-1.883 l 0.047,-21.135 c 2.098,-0.912 4.36,-1.367 6.786,-1.362 7.274,0.017 10.902,4.219 10.884,12.608 -0.01,4.129 -1.049,7.124 -3.119,8.988 z m 53.325,-12.021 -0.048,21.43 -9.437,-0.021 0.051,-22.708 c 0.005,-2.293 -0.45,-4.145 -1.363,-5.557 -0.917,-1.411 -2.717,-2.12 -5.402,-2.125 -2.427,-0.005 -5.408,0.676 -8.949,2.044 l -0.063,28.311 -9.437,-0.021 0.111,-49.608 9.436,0.021 -0.03,13.827 c 3.935,-1.628 7.736,-2.441 11.406,-2.433 5.636,0.012 13.762,0.659 13.725,16.84 z m 92.456,-16.111 9.437,0.021 -0.084,37.747 -9.437,-0.021 0.084,-37.747 z m 46.696,29.103 2.643,6.102 c -1.97,1.11 -4.085,1.99 -6.347,2.639 -2.263,0.649 -4.969,0.972 -8.113,0.965 -5.898,-0.013 -10.629,-1.825 -14.192,-5.438 -3.564,-3.611 -5.337,-8.368 -5.324,-14.266 0.014,-6.095 1.793,-10.891 5.339,-14.389 3.547,-3.497 8.302,-5.241 14.266,-5.228 3.014,0.006 5.586,0.307 7.715,0.901 2.127,0.595 4.371,1.453 6.729,2.571 l -2.67,6.187 c -2.356,-0.792 -4.208,-1.369 -5.548,-1.732 -1.345,-0.363 -2.9,-0.547 -4.67,-0.551 -3.735,-0.008 -6.572,1.1 -8.509,3.323 -1.938,2.224 -2.912,5.203 -2.921,8.938 -0.008,3.605 1,6.54 3.028,8.805 2.024,2.266 4.809,3.403 8.347,3.412 1.77,0.004 3.326,-0.173 4.673,-0.531 1.339,-0.356 3.193,-0.926 5.554,-1.708 z m -53.79,-12.801 -0.048,21.43 -9.338,-0.021 0.05,-22.609 c 0.016,-7.021 -4.21,-7.677 -6.766,-7.683 -2.295,-0.005 -5.344,0.676 -9.146,2.044 l -0.062,28.212 -9.339,-0.021 0.084,-37.747 6.683,0.015 1.175,2.755 c 4.462,-2.152 8.818,-3.225 13.081,-3.215 4.405,0.01 13.663,0.163 13.626,16.84 z m -59.894,-17.042 c -5.898,-0.013 -10.62,1.731 -14.167,5.227 -3.545,3.499 -5.327,8.294 -5.341,14.389 -0.009,3.801 0.784,7.178 2.385,10.131 1.6,2.953 3.854,5.268 6.769,6.946 2.913,1.676 6.301,2.52 10.169,2.528 3.866,0.009 7.274,-0.82 10.228,-2.483 2.955,-1.665 5.234,-3.969 6.848,-6.915 1.612,-2.946 2.423,-6.351 2.432,-10.218 0.013,-5.963 -1.73,-10.719 -5.228,-14.265 -3.5,-3.546 -8.195,-5.327 -14.095,-5.34 z m 6.866,28.62 c -1.809,2.095 -4.152,3.137 -7.035,3.131 -2.753,-0.006 -5.047,-1.076 -6.874,-3.21 -1.831,-2.133 -2.741,-5.102 -2.732,-8.903 0.008,-3.866 0.949,-6.879 2.821,-9.037 1.872,-2.159 4.15,-3.235 6.84,-3.229 2.752,0.006 5.059,1.077 6.922,3.21 1.862,2.134 2.791,5.167 2.783,9.099 -0.009,3.868 -0.918,6.848 -2.725,8.939 z\"\n      ></path>\n      <path\n        d=\"M 53.464,198.261 H 13.619 c 0,-0.001 0,-0.004 0,-0.006 0,-82.727 67.065,-149.791 149.788,-149.791 82.729,0 149.794,67.063 149.794,149.791 0,0.002 0,0.005 0,0.006 h -39.844 c 0.006,-0.435 0.017,-0.869 0.017,-1.306 0,-60.731 -49.234,-109.963 -109.967,-109.963 -60.727,0 -109.959,49.232 -109.959,109.963 -10e-4,0.437 0.011,0.871 0.016,1.306 z m 189.059,0 c 0.019,-0.705 0.053,-1.407 0.053,-2.117 0,-43.723 -35.444,-79.167 -79.167,-79.167 -43.722,0 -79.166,35.445 -79.166,79.167 0,0.71 0.035,1.412 0.054,2.117 h 39.67 c -0.085,-1.047 -0.14,-2.103 -0.14,-3.172 0,-21.861 17.721,-39.584 39.582,-39.584 21.862,0 39.583,17.723 39.583,39.584 0,1.069 -0.055,2.125 -0.139,3.172 h 39.67 z\"\n      ></path>\n    </g>\n  </svg>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\n\nexport default defineComponent({\n  props: {\n    className: {\n      type: String,\n      default: '',\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/ManageProductionForm.vue",
    "content": "<template>\n  <form class=\"pb-5 space-y-4\">\n    <div class=\"space-y-8\">\n      <div class=\"bg-white px-4 sm:px-6\">\n        <div class=\"-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap\">\n          <div class=\"ml-4 mt-4\">\n            <div class=\"flex items-center\">\n              <div class=\"flex-shrink-0\">\n                <AuphonicLogo className=\"mx-auto h-12 w-12 text-gray-400\" />\n              </div>\n              <div class=\"ml-4\">\n                <h3 class=\"text-base font-semibold leading-6 text-gray-900\">\n                  {{ production?.metadata?.title }}\n                </h3>\n                <p class=\"text-sm text-gray-500\">\n                  {{ new Date(Date.parse(production?.creation_time)).toLocaleString() }}\n                </p>\n              </div>\n            </div>\n          </div>\n          <div class=\"ml-4 mt-4 flex items-center space-x-4 text-xs\">\n            <span v-if=\"isSaving\" class=\"inline-flex items-center animate-pulse text-green-600\">\n              <CloudIcon class=\"mr-1 h-4 w-4\" aria-hidden=\"true\" />\n              {{ __('Saving', 'podlove-podcasting-plugin-for-wordpress') }}\n            </span>\n            <button\n              v-if=\"production.status !== 3\"\n              @click=\"showImportPage = !showImportPage\"\n              type=\"button\"\n              class=\"relative inline-flex items-center rounded-md bg-white px-3 py-2 font-medium text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\"\n            >\n              <ArrowDownTrayIcon class=\"-ml-0.5 mr-1.5 h-4 w-4 text-gray-400\" aria-hidden=\"true\" />\n              <span v-if=\"showImportPage\">{{\n                __('Hide Import', 'podlove-podcasting-plugin-for-wordpress')\n              }}</span>\n              <span v-if=\"!showImportPage\">{{\n                __('Show Import', 'podlove-podcasting-plugin-for-wordpress')\n              }}</span>\n            </button>\n            <button\n              @click=\"deselectProduction\"\n              type=\"button\"\n              class=\"relative inline-flex items-center rounded-md bg-white px-3 py-2 font-medium text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\"\n            >\n              <XIcon class=\"-ml-0.5 mr-1.5 h-4 w-4 text-gray-400\" aria-hidden=\"true\" />\n              <span>{{\n                __('Deselect Production', 'podlove-podcasting-plugin-for-wordpress')\n              }}</span>\n            </button>\n          </div>\n        </div>\n      </div>\n      <div class=\"space-y-4\">\n        <div v-if=\"showUploadScreen\">\n          {{ __('Uploading...', 'podlove-podcasting-plugin-for-wordpress') }}\n        </div>\n\n        <div v-if=\"showProcessingScreen\">\n          <div class=\"rounded-md bg-indigo-50 p-4\">\n            <div class=\"flex\">\n              <div class=\"flex-shrink-0\">\n                <DatabaseIcon class=\"h-5 w-5 text-indigo-400\" aria-hidden=\"true\" />\n              </div>\n              <div class=\"ml-3\">\n                <h3 class=\"text-sm font-medium text-indigo-800\">{{ production.status_string }}</h3>\n                <div class=\"mt-2 text-sm text-indigo-700\">\n                  <p>\n                    {{\n                      __(\n                        'Auphonic is now processing your production. Please wait for it to finish.',\n                        'podlove-podcasting-plugin-for-wordpress'\n                      )\n                    }}\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div v-if=\"production.status == 3 || showImportPage\">\n          <DonePage />\n        </div>\n\n        <div v-if=\"isMultitrack && showTrackEditor\">\n          <div class=\"bg-white shadow overflow-hidden rounded-md\">\n            <div class=\"px-6 pt-4 hidden md:block\">\n              <div class=\"md:grid md:grid-cols-3 md:gap-12\">\n                <div class=\"block text-sm font-medium text-gray-700 md:col-span-1\">\n                  {{ __('Track Identifier', 'podlove-podcasting-plugin-for-wordpress') }}\n                </div>\n                <div class=\"block text-sm font-medium text-gray-700 mt-5 md:mt-0 md:col-span-1\">\n                  {{ __('Upload Method', 'podlove-podcasting-plugin-for-wordpress') }}\n                </div>\n                <div class=\"block text-sm font-medium text-gray-700 mt-5 md:mt-0 md:col-span-1\">\n                  {{ __('Algorithm', 'podlove-podcasting-plugin-for-wordpress') }}\n                </div>\n                <div class=\"block w-8 md:col-span-1\"></div>\n              </div>\n            </div>\n\n            <ul role=\"list\" class=\"divide-y divide-gray-200\">\n              <li v-for=\"(track, index) in tracks\" :key=\"`xtrack-${index}`\" class=\"px-6 py-4\">\n                <div class=\"md:grid md:grid-cols-3 md:gap-12\">\n                  <div class=\"md:col-span-1\">\n                    <label\n                      :for=\"`track-id-${index}`\"\n                      class=\"block md:hidden text-sm font-medium text-gray-700\"\n                      >{{\n                        __('Track Identifier', 'podlove-podcasting-plugin-for-wordpress')\n                      }}</label\n                    >\n                    <input\n                      :id=\"`track-id-${index}`\"\n                      type=\"text\"\n                      :value=\"track.identifier_new\"\n                      @input=\"handleUpdateIdentifier($event, index)\"\n                      class=\"mt-1 max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md\"\n                    />\n                  </div>\n                  <div class=\"mt-5 md:mt-0 md:col-span-1\">\n                    <div class=\"sm:items-baseline\">\n                      <div class=\"mt-4 sm:mt-0\">\n                        <FileChooser\n                          :track_index=\"index\"\n                          :file_key=\"`${production.uuid}_t${index}`\"\n                        />\n                        <div\n                          v-if=\"track.input_file_name\"\n                          class=\"mt-2 px-3 py-2 bg-gray-100 rounded-md text-sm text-gray-700 break-all\"\n                        >\n                          {{ __('Uploaded File:', 'podlove-podcasting-plugin-for-wordpress') }}\n                          {{ track.input_file_name }}\n                        </div>\n\n                        <div v-if=\"uploadProgress(track.identifier) != null\">\n                          <div class=\"mt-2\" aria-hidden=\"true\">\n                            <div class=\"overflow-hidden rounded-full bg-gray-100\">\n                              <div\n                                class=\"h-2 rounded-full bg-indigo-600\"\n                                :style=\"{ width: uploadProgress(track.identifier) + '%' }\"\n                              ></div>\n                            </div>\n                            <div\n                              class=\"mt-1 hidden grid-cols-4 text-sm font-medium text-gray-600 sm:grid\"\n                            >\n                              <div>{{ uploadProgress(track.identifier) }}%</div>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                  <div class=\"md:col-span-1\">\n                    <div role=\"group\" class=\"bg-gr\" v-if=\"algorithmSettingsVisible(index)\">\n                      <div class=\"sm:items-baseline\">\n                        <div class=\"\">\n                          <div class=\"block md:hidden text-sm font-medium text-gray-700 py-2\">\n                            {{ __('Algorithm', 'podlove-podcasting-plugin-for-wordpress') }}\n                          </div>\n                          <div class=\"max-w-lg relative\">\n                            <div\n                              :title=\"__('Remove Track', 'podlove-podcasting-plugin-for-wordpress')\"\n                              @click=\"removeTrack(track.identifier)\"\n                              class=\"absolute z-10 right-0 top-0 cursor-pointer text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\"\n                            >\n                              <TrashIcon class=\"h-6 w-6\" aria-hidden=\"true\" />\n                            </div>\n                            <div class=\"space-y-4\">\n                              <div class=\"relative flex items-start\">\n                                <div class=\"flex items-center h-5\">\n                                  <input\n                                    :id=\"`track_${index}_filtering`\"\n                                    type=\"checkbox\"\n                                    class=\"focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded\"\n                                    :checked=\"track.filtering\"\n                                    @input=\"handleToggleFiltering($event, index)\"\n                                  />\n                                </div>\n                                <div class=\"ml-3 text-sm\">\n                                  <label :for=\"`track_${index}_filtering`\" class=\"text-gray-700\">{{\n                                    __('Filtering', 'podlove-podcasting-plugin-for-wordpress')\n                                  }}</label>\n                                </div>\n                              </div>\n                              <div>\n                                <div class=\"relative flex items-start\">\n                                  <div class=\"flex items-center h-5\">\n                                    <input\n                                      :id=\"`track_${index}_noisehum`\"\n                                      type=\"checkbox\"\n                                      class=\"focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded\"\n                                      :checked=\"track.noise_and_hum_reduction\"\n                                      @input=\"handleToggleNoiseHum($event, index)\"\n                                    />\n                                  </div>\n                                  <div class=\"ml-3 text-sm\">\n                                    <label :for=\"`track_${index}_noisehum`\" class=\"text-gray-700\">{{\n                                      __(\n                                        'Noise and Hum Reduction',\n                                        'podlove-podcasting-plugin-for-wordpress'\n                                      )\n                                    }}</label>\n                                  </div>\n                                </div>\n                              </div>\n                              <div>\n                                <div\n                                  class=\"relative flex justify-start align-middle items-center gap-3\"\n                                >\n                                  <div class=\"text-sm\">\n                                    <label :for=\"`track_${index}_fgbg`\" class=\"text-gray-700\">{{\n                                      __(\n                                        'Fore/Background',\n                                        'podlove-podcasting-plugin-for-wordpress'\n                                      )\n                                    }}</label>\n                                  </div>\n\n                                  <select\n                                    :value=\"track.fore_background\"\n                                    @input=\"handleSelectForeBackground($event, index)\"\n                                    :id=\"`track_${index}_fgbg`\"\n                                    class=\"mt-1 block w-[168px] pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md\"\n                                  >\n                                    <option value=\"auto\">\n                                      {{ __('Auto', 'podlove-podcasting-plugin-for-wordpress') }}\n                                    </option>\n                                    <option value=\"foreground\">\n                                      {{\n                                        __(\n                                          'Foreground Track',\n                                          'podlove-podcasting-plugin-for-wordpress'\n                                        )\n                                      }}\n                                    </option>\n                                    <option value=\"background\">\n                                      {{\n                                        __(\n                                          'Background Track',\n                                          'podlove-podcasting-plugin-for-wordpress'\n                                        )\n                                      }}\n                                    </option>\n                                    <option value=\"ducking\">\n                                      {{\n                                        __(\n                                          'Duck this Track',\n                                          'podlove-podcasting-plugin-for-wordpress'\n                                        )\n                                      }}\n                                    </option>\n                                    <option value=\"unchanged\">\n                                      {{\n                                        __(\n                                          'Unchanged (Foreground)',\n                                          'podlove-podcasting-plugin-for-wordpress'\n                                        )\n                                      }}\n                                    </option>\n                                  </select>\n                                </div>\n                              </div>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                  <!--\n                  <div class=\"w-8 md:col-span-1\">\n                    <span @click=\"removeTrack(track.identifier)\">X</span>\n                  </div>-->\n                </div>\n              </li>\n            </ul>\n            <div class=\"bg-gray-50 px-4 py-4 sm:px-6\">\n              <podlove-button variant=\"primary\" @click=\"addTrack\">{{\n                __('Add Track', 'podlove-podcasting-plugin-for-wordpress')\n              }}</podlove-button>\n            </div>\n          </div>\n        </div>\n        <div v-else-if=\"showTrackEditor\" class=\"grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6\">\n          <fieldset class=\"sm:col-span-4\">\n            <legend class=\"text-base font-medium text-gray-900\">\n              {{ __('Audio Source', 'podlove-podcasting-plugin-for-wordpress') }}\n            </legend>\n            <div class=\"mt-2\">\n              <FileChooser :file_key=\"production.uuid\" />\n\n              <div\n                v-if=\"production.input_file\"\n                class=\"mt-2 px-3 py-2 bg-gray-100 rounded-md text-sm text-gray-700 break-all\"\n              >\n                <span>\n                  {{ __('Uploaded File:', 'podlove-podcasting-plugin-for-wordpress') }}\n                  {{ production.input_file }}\n                </span>\n              </div>\n\n              <div v-if=\"uploadProgress('singletrack') != null\">\n                <div class=\"mt-2\" aria-hidden=\"true\">\n                  <div class=\"overflow-hidden rounded-full bg-gray-100\">\n                    <div\n                      class=\"h-2 rounded-full bg-indigo-600\"\n                      :style=\"{ width: uploadProgress('singletrack') + '%' }\"\n                    ></div>\n                  </div>\n                  <div class=\"mt-1 hidden grid-cols-4 text-sm font-medium text-gray-600 sm:grid\">\n                    <div>{{ uploadProgress('singletrack') }}%</div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </fieldset>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"pt-5\">\n      <div class=\"flex flex-col sm:flex-row gap-4 sm:gap-2 justify-between\">\n        <WebhookToggle />\n        <div class=\"flex justify-end gap-3\">\n          <a\n            :href=\"production?.edit_page\"\n            target=\"_blank\"\n            class=\"inline-flex items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-500 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2\"\n            >{{ __('Edit in Auphonic', 'podlove-podcasting-plugin-for-wordpress') }}\n            <ExternalLinkIcon class=\"ml-1 -mr-0.5 h-4 w-4\" aria-hidden=\"true\"\n          /></a>\n          <podlove-button\n            :variant=\"isSaving ? 'secondary-disabled' : 'secondary'\"\n            @click=\"saveProduction\"\n            >{{ __('Save Production', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button\n          >\n          <podlove-button variant=\"primary\" @click=\"startProduction\">{{\n            __('Start Production', 'podlove-podcasting-plugin-for-wordpress')\n          }}</podlove-button>\n        </div>\n      </div>\n    </div>\n  </form>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport PodloveButton from '@components/button/Button.vue'\nimport FileChooser from './FileChooser.vue'\n\nimport { State, selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport * as auphonic from '@store/auphonic.store'\nimport { Production, AudioTrack } from '@store/auphonic.store'\n\nimport DonePage from './production_form/DonePage.vue'\nimport WebhookToggle from './WebhookToggle.vue'\n\nimport AuphonicLogo from '../components/Logo.vue'\n\nimport {\n  XMarkIcon as XIcon,\n  CogIcon,\n  ChevronDownIcon,\n  ChevronRightIcon,\n  CircleStackIcon as DatabaseIcon,\n  ArrowTopRightOnSquareIcon as ExternalLinkIcon,\n  CloudIcon,\n  TrashIcon,\n  ArrowDownIcon,\n  ArrowDownTrayIcon,\n} from '@heroicons/vue/24/outline'\nimport { get } from 'lodash'\n\ntype AlgorithmType = { [key in number]?: any }\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    FileChooser,\n    CogIcon,\n    ChevronDownIcon,\n    ChevronRightIcon,\n    XIcon,\n    DatabaseIcon,\n    ExternalLinkIcon,\n    CloudIcon,\n    TrashIcon,\n    ArrowDownIcon,\n    ArrowDownTrayIcon,\n    AuphonicLogo,\n    DonePage,\n    WebhookToggle,\n  },\n\n  data() {\n    return {\n      algorithmSettings: {} as AlgorithmType,\n      showImportPage: false,\n    }\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        production: selectors.auphonic.production,\n        tracks: selectors.auphonic.tracks,\n        isSaving: selectors.auphonic.isSaving,\n        progress: (state: State) => (key: string) => selectors.progress.progress(state, key),\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    saveProduction() {\n      this.dispatch(\n        auphonic.saveProduction({\n          uuid: this.production.uuid,\n        })\n      )\n    },\n    startProduction() {\n      this.dispatch(auphonic.startProduction({ uuid: this.production.uuid }))\n    },\n    deselectProduction() {\n      this.dispatch(auphonic.deselectProduction())\n    },\n    addTrack() {\n      this.dispatch(auphonic.addTrack())\n    },\n    removeTrack(id: string) {\n      this.dispatch(auphonic.removeTrack(id))\n    },\n    updateTrack(prop: string, value: any, index: number) {\n      this.dispatch(\n        auphonic.updateTrack({\n          track: { [prop]: value },\n          index,\n        })\n      )\n    },\n    algorithmSettingsVisible(index: number): boolean {\n      // TODO: add UI to toggle all algorithm settings at once\n      return true\n      // return this.algorithmSettings[index] || false\n    },\n    toggleAlgorithmSettingVisible(index: number): void {\n      this.algorithmSettings[index] = !get(this.algorithmSettings, index, false)\n    },\n    handleSelectForeBackground(event: Event, index: number): void {\n      this.updateTrack('fore_background', (event.target as HTMLSelectElement).value, index)\n    },\n    handleToggleNoiseHum(event: Event, index: number): void {\n      this.updateTrack('noise_and_hum_reduction', (event.target as HTMLInputElement).checked, index)\n    },\n    handleToggleFiltering(event: Event, index: number): void {\n      this.updateTrack('filtering', (event.target as HTMLInputElement).checked, index)\n    },\n    handleUpdateIdentifier(event: Event, index: number): void {\n      this.updateTrack('identifier_new', (event.target as HTMLInputElement).value, index)\n    },\n    uploadProgress(key: string): string | null {\n      return this.state.progress(key)?.toString() || null\n    },\n  },\n\n  computed: {\n    production(): Production {\n      return this.state.production!\n    },\n    isSaving(): boolean {\n      return this.state.isSaving\n    },\n    showProcessingScreen(): boolean {\n      return [1, 4, 5, 6, 7, 8, 12, 13, 14].includes(this.production.status)\n    },\n    showUploadScreen(): boolean {\n      return this.production.status === 0\n    },\n    showTrackEditor(): boolean {\n      return [9, 10, 11].includes(this.production.status)\n    },\n    tracks(): AudioTrack[] {\n      return this.state.tracks || []\n    },\n    isMultitrack(): boolean {\n      return !!this.state.production?.is_multitrack\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/SelectPreset.vue",
    "content": "<template>\n  <Listbox as=\"div\" @update:modelValue=\"setPreset\" :value=\"currentPreset\">\n    <ListboxLabel class=\"block text-sm font-medium text-gray-600 sr-only\">\n      {{ __('Select Preset', 'podlove-podcasting-plugin-for-wordpress') }}\n    </ListboxLabel>\n    <div class=\"mt-1 relative\">\n      <ListboxButton\n        class=\"relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\"\n      >\n        <span class=\"w-full inline-flex truncate\">\n          <span v-if=\"currentPreset\" class=\"truncate\">{{ currentPreset._select.name }}</span>\n          <span v-else class=\"truncate\">&nbsp;</span>\n        </span>\n        <span class=\"absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n          <SelectorIcon class=\"h-5 w-5 text-gray-400\" aria-hidden=\"true\" />\n        </span>\n      </ListboxButton>\n\n      <transition\n        leave-active-class=\"transition ease-in duration-100\"\n        leave-from-class=\"opacity-100\"\n        leave-to-class=\"opacity-0\"\n      >\n        <ListboxOptions\n          class=\"absolute z-10 mt-1 w-full bg-white shadow-lg max-h-36 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm\"\n        >\n          <ListboxOption\n            as=\"template\"\n            v-for=\"preset in presets\"\n            :key=\"preset._select.date\"\n            :value=\"preset\"\n            v-slot=\"{ active }\"\n          >\n            <li\n              :class=\"[\n                active ? 'text-white bg-indigo-600' : 'text-gray-900',\n                'cursor-default select-none relative py-2 pl-3 pr-9',\n              ]\"\n            >\n              <div class=\"flex\">\n                <span :class=\"['font-normal', 'truncate']\">\n                  {{ preset._select.name }}\n                </span>\n                <span\n                  v-if=\"preset._select.is_multitrack\"\n                  :class=\"[active ? 'text-indigo-200' : 'text-gray-500', 'ml-2 truncate']\"\n                >\n                  {{ __('Multitrack', 'podlove-podcasting-plugin-for-wordpress') }}\n                </span>\n              </div>\n            </li>\n          </ListboxOption>\n        </ListboxOptions>\n      </transition>\n    </div>\n  </Listbox>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, ref } from 'vue'\nimport { selectors } from '@store'\nimport * as auphonic from '@store/auphonic.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport {\n  Listbox,\n  ListboxButton,\n  ListboxLabel,\n  ListboxOption,\n  ListboxOptions,\n} from '@headlessui/vue'\nimport { CheckIcon, ChevronUpDownIcon as SelectorIcon } from '@heroicons/vue/24/solid'\nimport { Preset } from '@store/auphonic.store'\n\ntype PresetWithSelectionData = Preset & {\n  _select: {\n    name: string\n    date: string\n    is_multitrack: boolean\n  }\n}\n\nexport default defineComponent({\n  components: {\n    Listbox,\n    ListboxButton,\n    ListboxLabel,\n    ListboxOption,\n    ListboxOptions,\n    CheckIcon,\n    SelectorIcon,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        presets: selectors.auphonic.presets,\n        currentPresetId: selectors.auphonic.preset,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    setPreset(preset: Preset) {\n      this.dispatch(auphonic.setPreset(preset.uuid))\n    },\n  },\n\n  computed: {\n    presets(): PresetWithSelectionData[] {\n      return (this.state.presets || [])\n        .map((preset: Preset) => {\n          const date = preset.creation_time.split('T')[0]\n          const name = preset.preset_name\n          const is_multitrack = preset.is_multitrack\n\n          return { ...preset, _select: { name, date, is_multitrack } }\n        })\n        .toSorted((a: PresetWithSelectionData, b: PresetWithSelectionData) => {\n          const nameA = a._select.name.toUpperCase()\n          const nameB = b._select.name.toUpperCase()\n\n          if (nameA < nameB) return -1\n          if (nameA > nameB) return 1\n          return 0\n        })\n    },\n    currentPreset(): PresetWithSelectionData | null | undefined {\n      const currentPresetId = this.state.currentPresetId\n      return this.presets.find((preset) => preset.uuid === currentPresetId)\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/SelectProduction.vue",
    "content": "<template>\n  <Listbox as=\"div\" @update:modelValue=\"setProduction\" :value=\"currentProduction\">\n    <ListboxLabel class=\"block text-sm font-medium text-gray-600 sr-only\">\n      {{ __('Select Existing Production', 'podlove-podcasting-plugin-for-wordpress') }}\n    </ListboxLabel>\n    <div class=\"mt-1 relative\">\n      <ListboxButton\n        class=\"relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\"\n      >\n        <span class=\"w-full inline-flex truncate\">\n          <span v-if=\"currentProduction\" class=\"truncate\">{{\n            currentProduction.metadata.title\n          }}</span>\n          <span v-else class=\"truncate\">&nbsp;</span>\n        </span>\n        <span class=\"absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n          <SelectorIcon class=\"h-5 w-5 text-gray-400\" aria-hidden=\"true\" />\n        </span>\n      </ListboxButton>\n\n      <transition\n        leave-active-class=\"transition ease-in duration-100\"\n        leave-from-class=\"opacity-100\"\n        leave-to-class=\"opacity-0\"\n      >\n        <ListboxOptions\n          class=\"absolute z-10 mt-1 w-full bg-white shadow-lg max-h-36 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm\"\n        >\n          <ListboxOption\n            as=\"template\"\n            v-for=\"production in productions\"\n            :key=\"production._select.date\"\n            :value=\"production\"\n            v-slot=\"{ active }\"\n          >\n            <li\n              :class=\"[\n                active ? 'text-white bg-indigo-600' : 'text-gray-900',\n                'cursor-default select-none relative py-2 pl-3 pr-9',\n              ]\"\n            >\n              <div class=\"flex justify-between\">\n                <span :class=\"['font-normal', 'truncate']\">\n                  <span :class=\"[active ? 'text-indigo-200' : 'text-gray-500', 'ml-2 truncate']\">\n                    {{ production._select.date }}\n                  </span>\n                  {{ production._select.name }}\n                </span>\n                <span\n                  :class=\"[\n                    active ? 'text-indigo-200' : 'text-gray-500',\n                    'ml-2 truncate flex-shrink-0',\n                  ]\"\n                >\n                  {{ production.status_string }}\n                </span>\n              </div>\n            </li>\n          </ListboxOption>\n        </ListboxOptions>\n      </transition>\n    </div>\n  </Listbox>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport * as auphonic from '@store/auphonic.store'\nimport { Production } from '@store/auphonic.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport {\n  Listbox,\n  ListboxButton,\n  ListboxLabel,\n  ListboxOption,\n  ListboxOptions,\n} from '@headlessui/vue'\nimport { CheckIcon, ChevronUpDownIcon as SelectorIcon } from '@heroicons/vue/24/solid'\n\ntype ProductionWithSelectionData = Production & {\n  _select: {\n    name: string\n    date: string\n  }\n}\n\nexport default defineComponent({\n  components: {\n    Listbox,\n    ListboxButton,\n    ListboxLabel,\n    ListboxOption,\n    ListboxOptions,\n    CheckIcon,\n    SelectorIcon,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        productions: selectors.auphonic.productions,\n        production: selectors.auphonic.production,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    setProduction(production: Production) {\n      this.dispatch(auphonic.setProduction(production))\n    },\n  },\n\n  computed: {\n    productions(): ProductionWithSelectionData[] {\n      return (this.state.productions || []).map((production: Production) => {\n        const date = production.creation_time.split('T')[0]\n        const name = production.metadata.title\n\n        return { ...production, _select: { name, date } }\n      })\n    },\n    currentProduction(): Production | null {\n      return this.state.production\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/StartScreen.vue",
    "content": "<template>\n  <div>\n    <div class=\"m-6 text-center max-w-5xl\">\n      <AuphonicLogo className=\"mx-auto h-16 w-16 text-gray-400\" />\n\n      <div class=\"w-full flex justify-center\" v-if=\"isInitializing\">\n        <div class=\"animate-pulse mt-4 flex space-x-4\">\n          <RefreshIcon class=\"animate-spin h-5 w-5 mr-3\" />\n          {{ __('Loading...', 'podlove-podcasting-plugin-for-wordpress') }}\n        </div>\n      </div>\n\n      <div :class=\"{ 'text-left': true, 'opacity-0': isInitializing }\">\n        <p class=\"mt-1 text-sm text-gray-500\">\n          {{\n            __(\n              'Manage your audio post production with Auphonic. Get started by selecting an existing production or create a new one from an Auphonic preset.', 'podlove-podcasting-plugin-for-wordpress'\n            )\n          }}\n        </p>\n        <div\n          class=\"sm:divide-x sm:divide-gray-200 mt-6 py-6 gap-8 sm:gap-0 grid grid-cols-1 sm:grid-cols-2\"\n        >\n          <div class=\"flow-root sm:px-6\">\n            <div\n              class=\"relative -m-2 p-2 flex items-center justify-around space-x-4 rounded-xl hover:bg-gray-50 focus-within:ring-2 focus-within:ring-indigo-500\"\n            >\n              <div>\n                <h3 class=\"text-sm font-medium text-gray-900\">\n                  {{ __('Create New Production from Preset', 'podlove-podcasting-plugin-for-wordpress') }}\n                </h3>\n              </div>\n            </div>\n            <div\n              class=\"mt-2 sm:mt-8 flex justify-center align-middle content-center items-center gap-3\"\n            >\n              <div class=\"w-full max-w-md\">\n                <SelectPreset />\n              </div>\n            </div>\n            <div\n              class=\"mt-10 flex flex-col justify-center align-middle content-center items-center gap-3\"\n            >\n              <podlove-button\n                :disabled=\"buttonState == 'idle'\"\n                :variant=\"buttonState == 'idle' ? 'primary-disabled' : 'primary'\"\n                @click=\"handleCreateProduction\"\n                ><plus-sm-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" />\n                {{\n                  buttonState == 'multi'\n                    ? __('Create Multitrack Production', 'podlove-podcasting-plugin-for-wordpress')\n                    : __('Create Production', 'podlove-podcasting-plugin-for-wordpress')\n                }}</podlove-button\n              >\n            </div>\n          </div>\n          <div class=\"flow-root sm:px-6\">\n            <div\n              class=\"relative -m-2 p-2 flex items-center justify-around space-x-4 rounded-xl hover:bg-gray-50 focus-within:ring-2 focus-within:ring-indigo-500\"\n            >\n              <div>\n                <h3 class=\"text-sm font-medium text-gray-900\">\n                  {{ __('Select Existing Production', 'podlove-podcasting-plugin-for-wordpress') }}\n                </h3>\n              </div>\n            </div>\n            <div\n              class=\"mt-2 sm:mt-8 flex justify-center align-middle content-center items-center gap-3\"\n            >\n              <div class=\"w-full max-w-md\">\n                <SelectProduction />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport Module from '@components/module/Module.vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport { PlusIcon as PlusSmIcon, ArrowPathIcon as RefreshIcon } from '@heroicons/vue/24/outline'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport * as auphonic from '@store/auphonic.store'\nimport SelectProduction from '../components/SelectProduction.vue'\nimport SelectPreset from '../components/SelectPreset.vue'\nimport AuphonicLogo from '../components/Logo.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    PodloveButton,\n    PlusSmIcon,\n    RefreshIcon,\n    SelectProduction,\n    SelectPreset,\n    AuphonicLogo,\n  },\n  data() {\n    return {}\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        presetId: selectors.auphonic.preset,\n        presets: selectors.auphonic.presets,\n        isInitializing: selectors.auphonic.isInitializing,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    handleCreateProduction() {\n      switch (this.buttonState) {\n        case 'single':\n          this.createProduction()\n          break\n\n        case 'multi':\n          this.createMultitrackProduction()\n          break\n\n        case 'idle':\n        // do nothing\n        default:\n          break\n      }\n    },\n    createProduction() {\n      this.dispatch(auphonic.createProduction())\n    },\n    createMultitrackProduction() {\n      this.dispatch(auphonic.createMultitrackProduction())\n    },\n  },\n\n  computed: {\n    preset(): auphonic.Preset | null {\n      return this.state.presets?.find((preset: auphonic.Preset) => preset.uuid === this.state.presetId) ?? null\n    },\n    isInitializing(): boolean {\n      return this.state.isInitializing\n    },\n    buttonState(): 'idle' | 'single' | 'multi' {\n      if (!this.preset) {\n        return 'idle'\n      }\n\n      return this.preset.is_multitrack ? 'multi' : 'single'\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/WebhookToggle.vue",
    "content": "<template>\n  <div>\n    <SwitchGroup as=\"div\" class=\"flex items-center\">\n      <Switch\n        :modelValue=\"enabled\"\n        @update:modelValue=\"handleUpdate\"\n        :class=\"[\n          enabled ? 'bg-indigo-600' : 'bg-gray-200',\n          'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',\n        ]\"\n      >\n        <span\n          aria-hidden=\"true\"\n          :class=\"[\n            enabled ? 'translate-x-5' : 'translate-x-0',\n            'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',\n          ]\"\n        />\n      </Switch>\n      <SwitchLabel as=\"span\" class=\"ml-3\">\n        <span class=\"text-sm text-gray-900\">{{\n          __('Publish Episode when Production is done', 'podlove-podcasting-plugin-for-wordpress')\n        }}</span>\n      </SwitchLabel>\n    </SwitchGroup>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue'\nimport { selectors } from '@store'\nimport * as auphonic from '@store/auphonic.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  components: {\n    Switch,\n    SwitchGroup,\n    SwitchLabel,\n  },\n  data() {\n    return {\n      enabled: false,\n    }\n  },\n  methods: {\n    handleUpdate(newValue: boolean) {\n      this.enabled = newValue\n      this.dispatch(auphonic.updateWebhook(newValue))\n    },\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        publishWhenDone: selectors.auphonic.publishWhenDone,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n  // fixme: set state initially when episode data is available; somewhere in saga/store?\n  mounted() {\n    this.enabled = this.state.publishWhenDone\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/DonePage.vue",
    "content": "<template>\n  <div>\n    <div class=\"rounded-md bg-green-50 p-4\" v-if=\"production && production.status == 3\">\n      <div class=\"flex\">\n        <div class=\"flex-shrink-0\">\n          <ClipboardCheckIcon class=\"h-5 w-5 text-green-400\" aria-hidden=\"true\" />\n        </div>\n        <div class=\"ml-3\">\n          <h3 class=\"text-sm font-medium text-green-800\">{{ __('Done', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n          <div class=\"mt-2 text-sm text-green-700\">\n            <p>\n              <a\n                :href=\"production.status_page\"\n                target=\"_blank\"\n                class=\"underline inline-flex items-center\"\n                >{{ __('View Results', 'podlove-podcasting-plugin-for-wordpress') }}\n                <ExternalLinkIcon class=\"ml-0.5 mr-1 h-4 w-4\" aria-hidden=\"true\"\n              /></a>\n              {{ __('on the Auphonic status page.', 'podlove-podcasting-plugin-for-wordpress') }}\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"rounded-md bg-yellow-50 p-4 mt-4\" v-if=\"production?.warning_message\">\n      <div class=\"flex\">\n        <div class=\"flex-shrink-0\">\n          <ExclamationIcon class=\"h-5 w-5 text-yellow-400\" aria-hidden=\"true\" />\n        </div>\n        <div class=\"ml-3\">\n          <h3 class=\"text-sm font-medium text-yellow-800\">{{ __('Warning', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n          <div class=\"mt-2 text-sm text-yellow-700\">\n            <p>\n              {{ production.warning_message }}\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <PlusTransferStatus v-if=\"plusFileStorageEnabled\" />\n\n    <div class=\"mt-4 overflow-hidden rounded-lg bg-white shadow\" v-if=\"visibleEntries.length > 0\">\n      <div class=\"p-6\">\n        <div>\n          <h3 class=\"text-lg font-medium leading-6 text-gray-900\">Import Metadata</h3>\n          <p class=\"mt-1 text-sm text-gray-500\">\n            {{ __('These values from your Auphonic Production differ from your local values:', 'podlove-podcasting-plugin-for-wordpress') }}\n          </p>\n        </div>\n\n        <div class=\"mt-6 flow-root\">\n          <ul role=\"list\" class=\"-my-5 divide-y divide-gray-200\">\n            <li v-for=\"entry in visibleEntries\" :key=\"entry.key\" class=\"py-4\">\n              <div class=\"flex items-center space-x-4\">\n                <div class=\"min-w-0 flex-1\">\n                  <p class=\"truncate text-sm text-gray-500\">\n                    <!-- TODO: needs better translation support, see https://github.com/podlove/podlove-publisher/issues/1337 -->\n                    <em>{{ entry.title }}</em> {{ __('in the Auphonic Production is:', 'podlove-podcasting-plugin-for-wordpress') }}\n                  </p>\n                  <p class=\"truncate text-sm font-medium text-gray-900\">\n                    {{ renderEntryPreview(entry) }}\n                  </p>\n                </div>\n                <div>\n                  <button\n                    @click.prevent=\"importMeta(entry.title, entry.there)\"\n                    class=\"inline-flex items-center rounded-full border border-gray-300 bg-white px-2.5 py-0.5 text-sm font-medium leading-5 text-gray-700 shadow-sm hover:bg-gray-50\"\n                    aria-label=\"Import from Auphonic\"\n                  >\n                    <!-- TODO: needs better translation support, see https://github.com/podlove/podlove-publisher/issues/1337 -->\n                    {{ __('Import', 'podlove-podcasting-plugin-for-wordpress')\n                    }}<span class=\"hidden sm:inline\">&nbsp;{{ __('from Auphonic', 'podlove-podcasting-plugin-for-wordpress') }}</span>\n                  </button>\n                </div>\n              </div>\n            </li>\n          </ul>\n        </div>\n        <div class=\"mt-6\">\n          <button\n            @click.prevent=\"importAllMeta\"\n            class=\"flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50\"\n          >\n            {{ __('Import all from Auphonic', 'podlove-podcasting-plugin-for-wordpress') }}\n          </button>\n        </div>\n      </div>\n    </div>\n    <div v-else class=\"mt-4 overflow-hidden rounded-lg bg-white shadow\">\n      <div class=\"p-6\">{{ __('Nothing to import', 'podlove-podcasting-plugin-for-wordpress') }}</div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { AuphonicChapter, Production } from '@store/auphonic.store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { parsed as parsedChapters } from '@store/chapters.store'\nimport * as plus from '@store/plus.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport {\n  ClipboardDocumentCheckIcon as ClipboardCheckIcon,\n  ArrowTopRightOnSquareIcon as ExternalLinkIcon,\n  ExclamationTriangleIcon as ExclamationIcon,\n} from '@heroicons/vue/24/outline'\nimport { PodloveChapter } from '../../../../types/chapters.types'\nimport PlusTransferStatus from './PlusTransferStatus.vue'\n\ntype Entry = {\n  key: number\n  title: string\n  here: any\n  there: any\n}\n\nexport default defineComponent({\n  components: {\n    ClipboardCheckIcon,\n    ExternalLinkIcon,\n    ExclamationIcon,\n    PlusTransferStatus,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        production: selectors.auphonic.production,\n        title: selectors.episode.title,\n        subtitle: selectors.episode.subtitle,\n        summary: selectors.episode.summary,\n        duration: selectors.episode.duration,\n        slug: selectors.episode.slug,\n        license_name: selectors.episode.license_name,\n        license_url: selectors.episode.license_url,\n        chapters: selectors.chapters.list,\n        episodeId: selectors.episode.id,\n        plusFeatures: selectors.plus.features\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  created() {\n    this.dispatch(plus.init())\n  },\n\n  methods: {\n    isDifferent(entry: Entry) {\n      switch (entry.title) {\n        case 'chapters':\n          const here = entry.here\n            .map((chapter: PodloveChapter) => {\n              return (\n                chapter.start + (chapter.title || '') + (chapter.href || '') + (chapter.image || '')\n              )\n            })\n            .join(';')\n\n          const there = entry.there\n            .map((chapter: AuphonicChapter) => {\n              return (\n                Math.round((chapter.start_output_sec || 0) * 1000) +\n                (chapter.title || '') +\n                (chapter.url || '') +\n                (chapter.image || '')\n              )\n            })\n            .join(';')\n\n          return here != there\n          break\n\n        default:\n          return entry.there != entry.here\n          break\n      }\n    },\n    renderEntryPreview(entry: Entry) {\n      switch (entry.title) {\n        case 'chapters':\n          const auphonicChapters: AuphonicChapter[] = entry.there\n          return auphonicChapters.map((chapter) => chapter.start + ' ' + chapter.title).join(' / ')\n          break\n\n        default:\n          return entry.there\n          break\n      }\n    },\n    importMeta(prop: string, value: any) {\n      switch (prop) {\n        case 'chapters':\n          const auphonicChapters: AuphonicChapter[] = value\n          const chapters: PodloveChapter[] = auphonicChapters.map((chapter) => {\n            return {\n              start: Math.round((chapter.start_output_sec || 0) * 1000),\n              title: chapter.title || '',\n              href: chapter.url || '',\n              // FIXME: chapter.image is an Auphonic URL which we can't use. We\n              // have to download the image and serve from WordPress.\n              // image: chapter.image || '',\n              image: '',\n            }\n          })\n\n          this.dispatch(parsedChapters(chapters))\n          break\n\n        default:\n          this.dispatch(updateEpisode({ prop, value }))\n          break\n      }\n    },\n    importAllMeta() {\n      this.visibleEntries.forEach((entry: Entry) => {\n        this.importMeta(entry.title, entry.there)\n      })\n    },\n  },\n\n  computed: {\n    production(): Production | null {\n      return this.state.production\n    },\n    entries(): any {\n      const production = this.production\n      const state = this.state\n\n      if (!production) {\n        return []\n      }\n\n      return [\n        { key: 1, title: 'title', here: state.title, there: production.metadata.title },\n        { key: 2, title: 'subtitle', here: state.subtitle, there: production.metadata.subtitle },\n        { key: 3, title: 'summary', here: state.summary, there: production.metadata.summary },\n        // { key: 4, title: 'tags', here: 'todo', there: production.metadata.tags.join(' , ') },\n        {\n          key: 5,\n          title: 'license_name',\n          here: state.license_name,\n          there: production.metadata.license,\n        },\n        {\n          key: 6,\n          title: 'license_url',\n          here: state.license_url,\n          there: production.metadata.license_url,\n        },\n        // { key: 7, title: 'image', here: 'todo', there: production.image },\n        // { key: 8, title: 'duration', here: state.duration, there: production.length_timestring },\n        { key: 9, title: 'slug', here: state.slug, there: production.output_basename },\n        { key: 10, title: 'chapters', here: state.chapters, there: production.chapters },\n      ]\n    },\n    visibleEntries(): Entry[] {\n      return this.entries.filter((e: Entry) => e.there && this.isDifferent(e))\n    },\n    plusFileStorageEnabled(): boolean {\n      return this.state.plusFeatures.fileStorage\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/PlusTransferStatus.vue",
    "content": "<template>\n  <div class=\"mt-4 overflow-hidden rounded-lg bg-white shadow\" v-if=\"showPlusTransferStatus\">\n    <div class=\"p-6\">\n      <TransferHeader />\n\n      <TransferStatusPanel\n        :status=\"plusTransferStatus || 'waiting_for_webhook'\"\n        :files=\"plusTransferFiles\"\n        :errors=\"plusTransferErrors\"\n        :can-reupload=\"canReupload\"\n        @action=\"triggerManualTransfer\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { PlusTransferFile, triggerPlusTransfer, loadPlusTransferStatus } from '@store/auphonic.store'\nimport { verifyAll } from '@store/mediafiles.store'\nimport type { Production } from '@store/auphonic.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport TransferHeader from './TransferHeader.vue'\nimport TransferStatusPanel from './TransferStatusPanel.vue'\n\nexport default defineComponent({\n  name: 'PlusTransferStatus',\n  components: {\n    TransferHeader,\n    TransferStatusPanel,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        production: selectors.auphonic.production,\n        plusTransferStatus: selectors.auphonic.plusTransferStatus,\n        plusTransferFiles: selectors.auphonic.plusTransferFiles,\n        plusTransferErrors: selectors.auphonic.plusTransferErrors,\n        lastTransferChangeTime: selectors.episode.auphonicPlusTransferChangeTime,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  mounted() {\n    this.loadPlusTransferStatus()\n  },\n\n  methods: {\n    loadPlusTransferStatus() {\n      if (!this.production?.uuid) return\n\n      this.dispatch(loadPlusTransferStatus({\n        production_uuid: this.production.uuid\n      }))\n    },\n\n    triggerManualTransfer() {\n      if (this.production?.uuid) {\n        this.dispatch(triggerPlusTransfer({ production_uuid: this.production.uuid }))\n      }\n    },\n\n    refreshEpisodeData() {\n      this.dispatch(verifyAll())\n    },\n  },\n\n  computed: {\n    production(): Production | null {\n      return this.state.production\n    },\n    plusTransferStatus(): 'waiting_for_webhook' | 'in_progress' | 'completed' | 'completed_with_errors' | 'failed' | undefined {\n      return this.state.plusTransferStatus\n    },\n    plusTransferFiles(): PlusTransferFile[] | undefined {\n      return this.state.plusTransferFiles\n    },\n    plusTransferErrors(): string | undefined {\n      return this.state.plusTransferErrors\n    },\n    lastTransferChangeTime(): string | null | undefined {\n      return this.state.lastTransferChangeTime\n    },\n    canReupload(): boolean {\n      return (\n        this.plusTransferStatus === 'completed' &&\n        this.production?.status_string === 'Done' &&\n        !!this.production?.change_time &&\n        !!this.lastTransferChangeTime &&\n        this.production.change_time !== this.lastTransferChangeTime\n      )\n    },\n    showPlusTransferStatus(): boolean {\n      return this.production?.status === 3\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/TransferFileItem.vue",
    "content": "<template>\n  <li class=\"flex items-center text-sm\">\n    <span class=\"flex-shrink-0 mr-2\">\n      <ArrowPathIcon v-if=\"file.status === 'processing'\" class=\"h-4 w-4 text-blue-500 animate-spin\" />\n      <CheckCircleIcon v-else-if=\"file.status === 'completed'\" class=\"h-4 w-4 text-green-500\" />\n      <XCircleIcon v-else-if=\"file.status === 'failed'\" class=\"h-4 w-4 text-red-500\" />\n      <ClockIcon v-else class=\"h-4 w-4 text-gray-400\" />\n    </span>\n    <span class=\"font-medium\">{{ file.filename }}</span>\n    <span class=\"ml-2 text-xs\" :class=\"{\n      'text-blue-600': file.status === 'processing',\n      'text-green-600': file.status === 'completed',\n      'text-red-600': file.status === 'failed',\n      'text-gray-500': file.status === 'pending'\n    }\">\n      {{ getFileStatusMessage(file) }}\n    </span>\n  </li>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, type PropType } from 'vue'\nimport { PlusTransferFile } from '@store/auphonic.store'\nimport {\n  ClockIcon,\n  ArrowPathIcon,\n  CheckCircleIcon,\n  XCircleIcon,\n} from '@heroicons/vue/24/outline'\n\nexport default defineComponent({\n  name: 'TransferFileItem',\n  components: {\n    ClockIcon,\n    ArrowPathIcon,\n    CheckCircleIcon,\n    XCircleIcon,\n  },\n  props: {\n    file: {\n      type: Object as PropType<PlusTransferFile>,\n      required: true\n    }\n  },\n  methods: {\n    getFileStatusMessage(file: PlusTransferFile): string {\n      switch (file.status) {\n        case 'pending':\n          return 'Waiting...'\n        case 'processing':\n          return 'Transferring...'\n        case 'completed':\n          return 'Completed'\n        case 'failed':\n          return file.message || 'Failed'\n        default:\n          return file.message || ''\n      }\n    },\n  }\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/TransferFileList.vue",
    "content": "<template>\n  <div v-if=\"files && files.length > 0\">\n    <p class=\"font-medium\">{{ title }}</p>\n    <ul class=\"mt-1 space-y-1\">\n      <TransferFileItem\n        v-for=\"file in files\"\n        :key=\"file.filename\"\n        :file=\"file\"\n      />\n    </ul>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, type PropType } from 'vue'\nimport { PlusTransferFile } from '@store/auphonic.store'\nimport TransferFileItem from './TransferFileItem.vue'\n\nexport default defineComponent({\n  name: 'TransferFileList',\n  components: {\n    TransferFileItem\n  },\n  props: {\n    files: {\n      type: Array as PropType<PlusTransferFile[]>,\n      default: () => []\n    },\n    title: {\n      type: String,\n      default: ''\n    }\n  }\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/TransferHeader.vue",
    "content": "<template>\n  <div class=\"mb-4\">\n    <h3 class=\"text-lg font-medium leading-6 text-gray-900\">PLUS Podcast File Hosting</h3>\n    <p class=\"mt-1 text-sm text-gray-500\">\n      {{ __('Auphonic files are automatically transferred to PLUS Podcast File Hosting.', 'podlove-podcasting-plugin-for-wordpress') }}\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n  name: 'TransferHeader'\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/components/production_form/TransferStatusPanel.vue",
    "content": "<template>\n  <!-- Waiting for webhook -->\n  <div class=\"rounded-md bg-blue-50 p-4\" v-if=\"status === 'waiting_for_webhook'\">\n    <div class=\"flex\">\n      <div class=\"flex-shrink-0\">\n        <ClockIcon class=\"h-5 w-5 text-blue-400\" aria-hidden=\"true\" />\n      </div>\n      <div class=\"ml-3\">\n        <h3 class=\"text-sm font-medium text-blue-800\">{{ __('Waiting for Transfer', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n        <div class=\"mt-2 text-sm text-blue-700\">\n          <p>{{ __('Files will be transferred automatically when the production is completed.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n        </div>\n        <div class=\"mt-4\">\n          <button\n            @click=\"$emit('action')\"\n            class=\"inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\"\n          >\n            {{ __('Transfer Now', 'podlove-podcasting-plugin-for-wordpress') }}\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- In progress -->\n  <div class=\"rounded-md bg-gray-50 p-4\" v-else-if=\"status === 'in_progress'\">\n    <div class=\"flex\">\n      <div class=\"flex-shrink-0\">\n        <ArrowPathIcon class=\"h-5 w-5 text-gray-400 animate-spin\" aria-hidden=\"true\" />\n      </div>\n      <div class=\"ml-3\">\n        <h3 class=\"text-sm font-medium text-gray-800\">{{ __('Transferring Files', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n        <div class=\"mt-2 text-sm text-gray-700\">\n          <p>{{ __('Files are being transferred to PLUS storage...', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n          <div class=\"mt-3\" v-if=\"files && files.length > 0\">\n            <ul class=\"space-y-1\">\n              <TransferFileItem\n                v-for=\"file in files\"\n                :key=\"file.filename\"\n                :file=\"file\"\n              />\n            </ul>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Completed -->\n  <div class=\"rounded-md bg-green-50 p-4\" v-else-if=\"status === 'completed'\">\n    <div class=\"flex\">\n      <div class=\"flex-shrink-0\">\n        <CheckCircleIcon class=\"h-5 w-5 text-green-400\" aria-hidden=\"true\" />\n      </div>\n      <div class=\"ml-3\">\n        <h3 class=\"text-sm font-medium text-green-800\">{{ __('Transfer Complete', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n        <div class=\"mt-2 text-sm text-green-700\">\n          <p>{{ __('All files have been transferred successfully to PLUS storage.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n          <TransferFileList\n            v-if=\"files && files.length > 0\"\n            :files=\"files\"\n            :title=\"__('Transferred files:', 'podlove-podcasting-plugin-for-wordpress')\"\n            class=\"mt-2\"\n          />\n        </div>\n        <div class=\"mt-4\" v-if=\"canReupload\">\n          <button\n            @click=\"$emit('action')\"\n            class=\"inline-flex items-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600\"\n          >\n            {{ __('Re-upload to PLUS', 'podlove-podcasting-plugin-for-wordpress') }}\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Completed with errors -->\n  <div class=\"rounded-md bg-yellow-50 p-4\" v-else-if=\"status === 'completed_with_errors'\">\n    <div class=\"flex\">\n      <div class=\"flex-shrink-0\">\n        <ExclamationTriangleIcon class=\"h-5 w-5 text-yellow-400\" aria-hidden=\"true\" />\n      </div>\n      <div class=\"ml-3\">\n        <h3 class=\"text-sm font-medium text-yellow-800\">{{ __('Transfer Completed with Errors', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n        <div class=\"mt-2 text-sm text-yellow-700\">\n          <p>{{ __('Some files were transferred successfully, but others failed.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n          <TransferFileList\n            v-if=\"files && files.length > 0\"\n            :files=\"files\"\n            :title=\"__('File transfer results:', 'podlove-podcasting-plugin-for-wordpress')\"\n            class=\"mt-2\"\n          />\n        </div>\n        <div class=\"mt-4\">\n          <button\n            @click=\"$emit('action')\"\n            class=\"inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-yellow-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-yellow-600\"\n          >\n            {{ __('Retry Failed Transfers', 'podlove-podcasting-plugin-for-wordpress') }}\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Failed -->\n  <div class=\"rounded-md bg-red-50 p-4\" v-else-if=\"status === 'failed'\">\n    <div class=\"flex\">\n      <div class=\"flex-shrink-0\">\n        <XCircleIcon class=\"h-5 w-5 text-red-400\" aria-hidden=\"true\" />\n      </div>\n      <div class=\"ml-3\">\n        <h3 class=\"text-sm font-medium text-red-800\">{{ __('Transfer Failed', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n        <div class=\"mt-2 text-sm text-red-700\">\n          <p>{{ __('Some files failed to transfer to PLUS storage.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n          <div class=\"mt-2\" v-if=\"errors\">\n            <p class=\"font-medium\">{{ __('Error details:', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n            <p class=\"text-sm text-red-600\">{{ errors }}</p>\n          </div>\n          <div class=\"mt-2\" v-if=\"files && files.length > 0\">\n            <p class=\"font-medium\">{{ __('File transfer results:', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n            <ul class=\"mt-1 list-disc list-inside\">\n              <li v-for=\"file in files\" :key=\"file.filename\" class=\"text-sm\">\n                <span class=\"font-medium\">{{ file.filename }}</span>\n                <span v-if=\"file.success\" class=\"text-green-600 ml-2\">✓</span>\n                <span v-else class=\"text-red-600 ml-2\">✗ {{ file.message }}</span>\n              </li>\n            </ul>\n          </div>\n        </div>\n        <div class=\"mt-4\">\n          <button\n            @click=\"$emit('action')\"\n            class=\"inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600\"\n          >\n            {{ __('Retry Transfer', 'podlove-podcasting-plugin-for-wordpress') }}\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, type PropType } from 'vue'\nimport { PlusTransferFile } from '@store/auphonic.store'\nimport {\n  ClockIcon,\n  ArrowPathIcon,\n  CheckCircleIcon,\n  XCircleIcon,\n  ExclamationTriangleIcon,\n} from '@heroicons/vue/24/outline'\nimport TransferFileList from './TransferFileList.vue'\nimport TransferFileItem from './TransferFileItem.vue'\n\nexport default defineComponent({\n  name: 'TransferStatusPanel',\n  components: {\n    ClockIcon,\n    ArrowPathIcon,\n    CheckCircleIcon,\n    XCircleIcon,\n    ExclamationTriangleIcon,\n    TransferFileList,\n    TransferFileItem,\n  },\n  props: {\n    status: {\n      type: String as PropType<'waiting_for_webhook' | 'in_progress' | 'completed' | 'completed_with_errors' | 'failed'>,\n      required: true\n    },\n    files: {\n      type: Array as PropType<PlusTransferFile[]>,\n      default: () => []\n    },\n    errors: {\n      type: String,\n      default: undefined\n    },\n    canReupload: {\n      type: Boolean,\n      default: false\n    }\n  },\n  emits: ['action']\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/auphonic/index.ts",
    "content": "import Auphonic from './Auphonic.vue'\n\nexport default Auphonic\n"
  },
  {
    "path": "client/src/modules/chapters/Chapters.vue",
    "content": "<template>\n  <module name=\"chapters\" :title=\"__('Chapter Marks', 'podlove-podcasting-plugin-for-wordpress')\">\n    <template v-slot:actions>\n      <chapters-import class=\"mr-1\"></chapters-import>\n      <chapters-export></chapters-export>\n    </template>\n    <chapters-form></chapters-form>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { TabsContainer, Tab } from '@components/tabs'\nimport Module from '@components/module/Module.vue'\n\nimport { injectAppDispatch } from '@store/vue'\n\nimport * as chapters from '@store/chapters.store'\n\nimport ChaptersForm from './components/Form.vue'\nimport ChaptersImport from './components/Import.vue'\nimport ChaptersExport from './components/Export.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    TabsContainer,\n    Tab,\n    ChaptersForm,\n    ChaptersImport,\n    ChaptersExport\n  },\n\n  setup(): { dispatch: Function } {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  created() {\n    this.dispatch(chapters.init())\n  }\n})\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/chapters/components/Export.vue",
    "content": "<template>\n  <div>\n    <popover>\n      <template v-slot:trigger>\n        <podlove-button variant=\"secondary\" size=\"small\">{{ __('Export', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n      </template>\n      <template v-slot:content>\n        <div class=\"bg-white p-7 -translate-x-full z-10 mt-3 transform ml-16 rounded-lg shadow-lg\">\n          <a\n            @click=\"download('psc')\"\n            class=\"\n              flex\n              items-center\n              p-2\n              -m-3\n              transition\n              duration-150\n              ease-in-out\n              rounded-lg\n              cursor-pointer\n              bg-gray-100\n              hover:bg-indigo-100\n              focus:outline-none\n              focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-50\n              mb-4\n            \"\n          >\n            <div class=\"text-sm font-medium text-gray-900 truncate w-full mr-2\">\n              {{ __('Podlove Simple Chapters', 'podlove-podcasting-plugin-for-wordpress') }}\n            </div>\n            <div class=\"text-sm text-gray-500\">.psc</div>\n          </a>\n          <a\n            @click=\"download('mp4')\"\n            class=\"\n              flex\n              items-center\n              p-2\n              -m-3\n              transition\n              duration-150\n              ease-in-out\n              rounded-lg\n              cursor-pointer\n              bg-gray-100\n              hover:bg-indigo-100\n              focus:outline-none\n              focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-50\n            \"\n          >\n            <div class=\"text-sm font-medium text-gray-900 truncate w-full mr-2\">{{ __('MP4Chaps', 'podlove-podcasting-plugin-for-wordpress') }}</div>\n            <div class=\"text-sm text-gray-500\">.txt</div>\n          </a>\n        </div>\n      </template>\n    </popover>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\nimport { download } from '@store/chapters.store'\nimport { injectAppDispatch } from '@store/vue'\nimport Popover from '@components/popover/Popover.vue'\nimport PodloveButton from '@components/button/Button.vue'\n\nexport default defineComponent({\n  components: {\n    Popover,\n    PodloveButton,\n  },\n  setup(): { dispatch: Function } {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n  methods: {\n    download(type: 'psc' | 'mp4') {\n      this.dispatch(download(type))\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/chapters/components/Form.vue",
    "content": "<template>\n  <div v-if=\"chapters.length > 0\">\n    <div class=\"md:flex p-2 sm:block\">\n      <div class=\"w-full\">\n        <div class=\"h-96 overflow-x-auto\" ref=\"chaptersContainer\">\n          <table class=\"min-w-full divide-y divide-gray-200 mb-2\">\n            <tbody ref=\"chapters\">\n              <tr\n                @click=\"selectChapter(index)\"\n                v-for=\"(chapter, index) in chapters\"\n                :key=\"`chapter-${index}`\"\n                class=\"cursor-pointer\"\n                :class=\"{\n                  'bg-indigo-100': selectedIndex === index,\n                  active: selectedIndex === index,\n                  'bg-white': index % 2 === 0 && selectedIndex !== index,\n                  'bg-gray-50': index % 2 !== 0 && selectedIndex !== index,\n                }\"\n              >\n                <td class=\"px-2 py-2 w-16\" v-if=\"hasChapterImages\">\n                  <img class=\"w-12 h-12 rounded\" v-if=\"chapter?.image\" :src=\"chapter?.image\" />\n                </td>\n                <td\n                  class=\"px-3 py-2 w-32 whitespace-nowrap text-sm font-medium text-gray-900 tabular-nums\"\n                >\n                  {{ chapter.start }}\n                </td>\n                <td class=\"px-3 py-2 whitespace-nowrap text-sm text-gray-600\">\n                  {{ chapter.title }}\n                </td>\n                <td\n                  class=\"px-3 py-2 whitespace-nowrap text-sm text-gray-600 text-right tabular-nums\"\n                >\n                  {{ chapter.duration }}\n                </td>\n              </tr>\n            </tbody>\n          </table>\n        </div>\n      </div>\n      <div v-if=\"state.selected\" class=\"md:w-4/12 sm:w-full md:mx-4 md:my-2 mt-0\">\n        <div class=\"mb-5 mt-2\">\n          <label for=\"chapter-title\" class=\"block text-sm font-medium text-gray-700\">{{\n            __('Title', 'podlove-podcasting-plugin-for-wordpress')\n          }}</label>\n          <div class=\"mt-1\">\n            <input\n              name=\"chapter-title\"\n              type=\"text\"\n              class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n              @change=\"updateChapter('title', $event)\"\n              :value=\"state.selected.title\"\n            />\n          </div>\n        </div>\n        <div class=\"mb-5\">\n          <label for=\"chapter-href\" class=\"block text-sm font-medium text-gray-700\"\n            >Url <span class=\"text-xs\">{{ __('(optional)', 'podlove-podcasting-plugin-for-wordpress') }}</span></label\n          >\n          <div class=\"mt-1\">\n            <input\n              name=\"chapter-href\"\n              type=\"text\"\n              class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n              @change=\"updateChapter('href', $event)\"\n              :value=\"state.selected.href\"\n            />\n          </div>\n        </div>\n        <div class=\"mb-5\">\n          <label for=\"chapter-start\" class=\"block text-sm font-medium text-gray-700\">{{\n            __('Start', 'podlove-podcasting-plugin-for-wordpress')\n          }}</label>\n          <div class=\"mt-1\">\n            <input\n              name=\"chapter-title\"\n              type=\"text\"\n              class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n              @change=\"updateChapter('start', $event)\"\n              :value=\"formatTime(state.selected.start)\"\n            />\n          </div>\n        </div>\n        <div class=\"mb-5\">\n          <label for=\"chapter-image\" class=\"block text-sm font-medium text-gray-700\"\n            >{{ __('Image', 'podlove-podcasting-plugin-for-wordpress') }} <span class=\"text-xs\">{{ __('(optional)', 'podlove-podcasting-plugin-for-wordpress') }}</span></label\n          >\n          <div class=\"mt-1 relative\">\n            <input\n              name=\"chapter-image\"\n              type=\"text\"\n              class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n              @change=\"updateChapter('image', $event)\"\n              :value=\"state.selected.image\"\n            />\n            <button\n              @click.prevent=\"selectImage()\"\n              :title=\"__('Select Chapter Image', 'podlove-podcasting-plugin-for-wordpress')\"\n              class=\"absolute right-2 top-1/2 -mt-3 text-gray-400 hover:text-gray-700\"\n            >\n              <upload-icon class=\"w-6 h-6\" />\n            </button>\n          </div>\n        </div>\n        <div class=\"mb-5 ml-1\">\n          <podlove-button variant=\"danger\" @click=\"removeChapter()\">{{\n            __('Delete Chapter', 'podlove-podcasting-plugin-for-wordpress')\n          }}</podlove-button>\n        </div>\n      </div>\n    </div>\n    <div class=\"mt-5 ml-5 pb-5\">\n      <podlove-button variant=\"primary\" @click=\"addChapter()\">\n        <plus-sm-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" /> {{ __('Add Chapter', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n    </div>\n  </div>\n  <div v-else class=\"text-center h-96 flex items-center justify-center flex-col\">\n    <bookmark-alt-icon class=\"mx-auto h-12 w-12 text-gray-400\" />\n\n    <h3 class=\"mt-2 text-sm font-medium text-gray-900\">{{ __('No chapters', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n    <p class=\"mt-1 text-sm text-gray-500\">{{ __('Get started by creating a new chapter.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n    <div class=\"mt-6\">\n      <podlove-button variant=\"primary\" @click=\"addChapter()\" class=\"ml-1\">\n        <plus-sm-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" /> {{ __('Add Chapter', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent, nextTick } from 'vue'\nimport Timestamp from '@lib/timestamp'\nimport { get } from 'lodash'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport {\n  select as selectChapter,\n  update as updateChapter,\n  remove as removeChapter,\n  add as addChapter,\n  selectImage,\n} from '@store/chapters.store'\nimport {\n  PlusIcon as PlusSmIcon,\n  BookmarkIcon as BookmarkAltIcon,\n  ArrowUpTrayIcon as UploadIcon,\n} from '@heroicons/vue/24/outline'\n\nimport PodloveButton from '@components/button/Button.vue'\nimport { PodloveChapter } from '../../../types/chapters.types'\n\ninterface Chapter {\n  index: number\n  title: string\n  duration: string\n  start: string\n  image?: string\n}\n\nexport default defineComponent({\n  components: { PodloveButton, PlusSmIcon, BookmarkAltIcon, UploadIcon },\n\n  setup() {\n    return {\n      state: mapAppState({\n        chapters: selectors.chapters.list,\n        selected: selectors.chapters.selected,\n        selectedIndex: selectors.chapters.selectedIndex,\n        episodeDuration: selectors.episode.duration,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  computed: {\n    selectedIndex(): number | null {\n      return this.state.selectedIndex\n    },\n    episodeDuration(): number {\n      return this.state.episodeDuration\n        ? Timestamp.fromString(this.state.episodeDuration).totalMs\n        : 0\n    },\n    chapters(): Chapter[] {\n      return this.state.chapters.reduce<Chapter[]>(\n        (result, chapter, chapterIndex, chapters) => {\n          const next = get(chapters, chapterIndex + 1)\n          const isLastChapter: boolean = next === undefined\n          let durationMs: number\n\n          if (isLastChapter) {\n            durationMs = this.episodeDuration ? this.episodeDuration - (chapter.start || 0) : -1\n          } else {\n            durationMs = (next.start || 0) - (chapter.start || 0)\n          }\n\n          const duration: string =\n            durationMs <= 0 ? 'Unknown' : new Timestamp(durationMs).prettyShort\n\n          return [\n            ...result,\n            {\n              index: chapterIndex,\n              title: chapter.title,\n              start: chapter.start ? new Timestamp(chapter.start).pretty : new Timestamp(0).pretty,\n              image: chapter.image,\n              duration,\n            },\n          ]\n        },\n        [] as Chapter[]\n      )\n    },\n    hasChapterImages(): boolean {\n      return this.chapters.reduce((agg: boolean, chapter: Chapter) => agg || !!chapter.image, false)\n    },\n  },\n\n  methods: {\n    // Store Actions\n    selectChapter(index: number) {\n      if (index === this.state.selectedIndex) {\n        this.dispatch(selectChapter(null))\n      } else {\n        this.dispatch(selectChapter(index))\n      }\n    },\n    updateChapter(prop: 'title' | 'href' | 'start' | 'image', event: Event) {\n      const raw = (event.target as HTMLInputElement).value\n\n      if (this.state.selectedIndex === null) {\n        return\n      }\n\n      let value: any\n\n      switch (prop) {\n        case 'start':\n          value = Timestamp.fromString(raw).totalMs\n          break\n        default:\n          value = raw\n      }\n\n      this.dispatch(\n        updateChapter({\n          chapter: {\n            [prop]: value,\n          },\n          index: this.state.selectedIndex,\n        })\n      )\n    },\n    removeChapter() {\n      if (this.state.selectedIndex === null) {\n        return\n      }\n\n      this.dispatch(removeChapter(this.state.selectedIndex))\n    },\n    async addChapter() {\n      this.dispatch(addChapter())\n      this.dispatch(selectChapter(this.state.chapters.length - 1))\n\n      const container: HTMLElement = this.$refs.chaptersContainer as HTMLElement\n\n      await nextTick()\n\n      container.scrollTo({\n        top: container.scrollHeight - container.clientHeight,\n        behavior: 'smooth',\n      })\n    },\n\n    // Formatters\n    formatTime(value: number): string {\n      return new Timestamp(value).pretty\n    },\n\n    selectImage() {\n      this.dispatch(selectImage())\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/chapters/components/Import.vue",
    "content": "<template>\n  <tooltip>\n    <template v-slot:trigger>\n      <podlove-button variant=\"secondary\" size=\"small\" @click=\"simulateImportClick()\"\n        >{{ __('Import', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button\n      >\n      <input ref=\"import\" type=\"file\" @change=\"importChapters\" class=\"hidden\" />\n    </template>\n    <template v-slot:content>\n      <div class=\"text-xs\">\n        <p class=\"text-gray-600 leading-3 font-semibold mb-2\">{{ __('Accepts:', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n        <ul class=\"text-gray-500 ml-1\">\n          <li class=\"mb-1\">\n            <a\n              class=\"text-blue-500 underline\"\n              href=\"https://podlove.org/simple-chapters/\"\n              target=\"_blank\"\n              >{{ __('Podlove Simple Chapters', 'podlove-podcasting-plugin-for-wordpress') }}</a\n            >\n            (<code>.psc</code>),\n          </li>\n          <li class=\"mb-1\">\n            <a class=\"text-blue-500 underline\" href=\"http://www.audacityteam.org\" target=\"_blank\"\n              >{{ __('Audacity', 'podlove-podcasting-plugin-for-wordpress') }}</a\n            >\n            {{ __('Track Labels', 'podlove-podcasting-plugin-for-wordpress') }},\n          </li>\n          <li class=\"mb-1\">\n            <a class=\"text-blue-500 underline\" href=\"https://hindenburg.com\" target=\"_blank\"\n              >{{ __('Hindenburg', 'podlove-podcasting-plugin-for-wordpress') }}</a\n            >\n            {{ __('project files and MP4Chaps', 'podlove-podcasting-plugin-for-wordpress') }} (<code>.txt</code>)\n          </li>\n        </ul>\n      </div>\n    </template>\n  </tooltip>\n</template>\n\n<script lang=\"ts\">\nimport { get } from 'lodash'\nimport { parse as parseChapters } from '@store/chapters.store'\nimport { defineComponent } from '@vue/runtime-core'\nimport { injectAppDispatch } from '@store/vue'\n\nimport PodloveButton from '@components/button/Button.vue'\nimport Tooltip from '@components/tooltip/Tooltip.vue'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Tooltip,\n  },\n\n  setup() {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    simulateImportClick() {\n      ;(this.$refs.import as HTMLInputElement).click()\n    },\n    importChapters() {\n      const fileInput = this.$refs.import as HTMLInputElement\n\n      if (!fileInput) {\n        return\n      }\n\n      try {\n        const reader: any = new FileReader()\n\n        reader.onload = (event: any) => {\n          this.dispatch(parseChapters(event.target.result))\n        }\n\n        reader\n          .readAsText(get(fileInput, ['files', 0], ''))(\n            // reset import element\n            this.$refs.importForm as HTMLFormElement\n          )\n          .reset()\n      } catch (err) {}\n    },\n  },\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/chapters/index.ts",
    "content": "import Chapters from './Chapters.vue';\n\nexport default Chapters;\n"
  },
  {
    "path": "client/src/modules/contributors/Contributors.vue",
    "content": "<template>\n  <module name=\"chapters\" :title=\"__('Contributors', 'podlove-podcasting-plugin-for-wordpress')\" class=\"overflow-hidden\">\n    <template v-slot:actions> </template>\n    <ul role=\"list\" class=\"divide-y divide-gray-200\">\n      <li\n        class=\"mb-0\"\n        v-for=\"(contribution, index) in state.contributions\"\n        :key=\"contribution.position\"\n      >\n        <Contribution\n          v-if=\"contribution?.contributor_id\"\n          :data=\"contribution\"\n          :first=\"index === 0\"\n          :last=\"index === state.contributions.length - 1\"\n        />\n      </li>\n      <li class=\"mb-0\" v-if=\"addContributorInput\">\n        <AddContribution\n          @addContributor=\"addContributor($event)\"\n          @createContributor=\"createContributor($event)\"\n          @close=\"closeAddContributor()\"\n        />\n      </li>\n    </ul>\n    <div class=\"py-5 px-6 border-t border-gray-200\">\n      <podlove-button variant=\"primary\" @click=\"showAddContributor()\">\n        <plus-sm-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" /> {{ __('Add Contributor', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport Module from '@components/module/Module.vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport Contribution from './components/Contribution.vue'\nimport AddContribution from './components/AddContribution.vue'\n\nimport { PlusIcon as PlusSmIcon } from '@heroicons/vue/24/outline'\n\nimport { selectors } from '@store'\nimport * as contributors from '@store/contributors.store'\nimport * as episode from '@store/episode.store'\nimport { PodloveEpisodeContribution } from '../../types/episode.types'\nimport { PodloveContributor, PodloveRole, PodloveGroup } from '../../types/contributors.types'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\ntype ContributionViewModel = Partial<Omit<PodloveContributor, 'id'>> & {\n  id: string | number\n  contributor_id: string | number\n  role_id: number\n  group_id: number\n  position: number\n  comment: string\n}\n\nexport default defineComponent({\n  components: {\n    Module,\n    PodloveButton,\n    PlusSmIcon,\n    Contribution,\n    AddContribution,\n  },\n\n  data() {\n    return {\n      addContributorInput: false,\n    }\n  },\n\n  setup(): {\n    dispatch: Function\n    state: {\n      contributions: ContributionViewModel[]\n      roles: PodloveRole[]\n      groups: PodloveGroup[]\n    }\n  } {\n    return {\n      dispatch: injectAppDispatch(),\n      state: mapAppState({\n        contributions: selectors.episode.contributions,\n        roles: selectors.contributors.roles,\n        groups: selectors.contributors.groups,\n      }),\n    }\n  },\n\n  created() {\n    this.dispatch(contributors.init())\n  },\n\n  methods: {\n    showAddContributor() {\n      this.addContributorInput = true\n    },\n    addContributor(contributor: PodloveContributor) {\n      this.addContributorInput = false\n      this.dispatch(episode.addContribution(contributor))\n    },\n    createContributor(name: string) {\n      this.addContributorInput = false\n      this.dispatch(episode.createContribution(name))\n    },\n    closeAddContributor() {\n      this.addContributorInput = false\n    },\n  },\n})\n</script>\n\n<style lang=\"postcss\"></style>\n"
  },
  {
    "path": "client/src/modules/contributors/components/AddContribution.vue",
    "content": "<template>\n  <div class=\"block hover:bg-gray-50\">\n    <div class=\"flex items-center px-4 py-4 sm:px-6\">\n      <div class=\"flex min-w-0 flex-1 items-center\">\n        <div class=\"min-w-0 flex-1 px-4 md:grid md:grid-cols-2 md:gap-4\">\n          <Combobox as=\"div\" v-model=\"data\">\n            <ComboboxLabel class=\"block text-sm font-medium leading-6 text-gray-900\">{{\n              __('Select Contributor', 'podlove-podcasting-plugin-for-wordpress')\n            }}</ComboboxLabel>\n            <div class=\"relative mt-2 max-w-sm\">\n              <ComboboxInput\n                ref=\"trigger\"\n                class=\"w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6\"\n                @change=\"filterContributors($event)\"\n                :displayValue=\"() => query\"\n              />\n              <ComboboxButton\n                class=\"absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none\"\n              >\n                <ChevronDownIcon class=\"h-5 w-5 text-gray-400\" aria-hidden=\"true\" />\n              </ComboboxButton>\n            </div>\n            <div ref=\"container\">\n              <ComboboxOptions\n                v-if=\"filteredContributors.length > 0\"\n                class=\"absolute max-w-sm z-50 mt-1 max-h-56 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm\"\n              >\n                <ComboboxOption\n                  v-for=\"contributor in filteredContributors\"\n                  :key=\"contributor.id || 'create'\"\n                  :value=\"contributor\"\n                  as=\"template\"\n                  v-slot=\"{ active, selected }\"\n                >\n                  <li\n                    :class=\"[\n                      'relative cursor-default select-none py-2 pl-3 pr-9',\n                      active ? 'bg-indigo-600 text-white' : 'text-gray-900',\n                    ]\"\n                  >\n                    <div class=\"flex items-center\">\n                      <img\n                        v-if=\"contributor.avatar_url\"\n                        :src=\"contributor.avatar_url\"\n                        alt=\"\"\n                        class=\"h-6 w-6 flex-shrink-0 rounded-full\"\n                        @error=\"contributor.avatar_url = ''\"\n                      />\n                      <UserCircleIcon\n                        v-if=\"!contributor.avatar_url\"\n                        class=\"h-6 w-6 flex-shrink-0 rounded-full text-gray-500\"\n                      />\n                      <span :class=\"['ml-3 truncate', selected && 'font-semibold']\">\n                        {{ contributorName(contributor) }}\n                      </span>\n                    </div>\n\n                    <span\n                      v-if=\"selected\"\n                      :class=\"[\n                        'absolute inset-y-0 right-0 flex items-center pr-4',\n                        active ? 'text-white' : 'text-indigo-600',\n                      ]\"\n                    >\n                      <CheckIcon class=\"h-5 w-5\" aria-hidden=\"true\" />\n                    </span>\n                  </li>\n                </ComboboxOption>\n              </ComboboxOptions>\n            </div>\n          </Combobox>\n        </div>\n      </div>\n      <div class=\"flex space-x-2 justify-end\">\n        <button class=\"text-red-600\" @click=\"close()\">\n          <x-icon class=\"w-5 h-5\" />\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { get } from 'lodash'\n\nimport { XMarkIcon as XIcon, CheckIcon, ChevronDownIcon, UserCircleIcon } from '@heroicons/vue/24/outline'\n\nimport {\n  Combobox,\n  ComboboxButton,\n  ComboboxInput,\n  ComboboxLabel,\n  ComboboxOption,\n  ComboboxOptions,\n} from '@headlessui/vue'\n\nimport { selectors } from '@store'\nimport { PodloveContributor } from '../../../types/contributors.types'\nimport { PodloveEpisodeContribution } from '../../../types/episode.types'\nimport { usePopper } from '@lib/popper'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\ntype ContributionOption = Omit<PodloveEpisodeContribution, 'id' | 'contributor_id'> & {\n  id: string | number\n  contributor_id: string | number\n}\n\ntype CreateContributorOption = {\n  id: null\n  avatar_url: null\n  publicname?: string\n  realname: string\n  nickname?: string\n}\n\ntype ContributorListOption = PodloveContributor | CreateContributorOption\n\nconst getContributorName = (contributor?: Partial<ContributorListOption> | null) =>\n  contributor?.publicname || contributor?.realname || contributor?.nickname || ''\n\nexport default defineComponent({\n  data() {\n    return {\n      query: '',\n      data: '',\n    }\n  },\n  emits: ['addContributor', 'createContributor', 'close'],\n  setup(): {\n    dispatch: Function\n    state: {\n      contributors: PodloveContributor[]\n      episodeContributions: ContributionOption[]\n    }\n    trigger: any\n    container: any\n  } {\n    let [trigger, container] = usePopper({\n      placement: 'bottom-end',\n      strategy: 'fixed',\n      modifiers: [\n        {\n          name: 'offset',\n          options: { offset: [0, 10] },\n        },\n        {\n          name: 'sameWidth',\n          enabled: true,\n          fn: ({ state }) => {\n            state.styles.popper.width = `${state.rects.reference.width}px`\n          },\n          phase: 'beforeWrite',\n          requires: ['computeStyles'],\n        },\n      ],\n    })\n\n    return {\n      dispatch: injectAppDispatch(),\n      state: mapAppState({\n        contributors: selectors.contributors.contributors,\n        episodeContributions: selectors.episode.contributions,\n      }),\n      trigger,\n      container,\n    }\n  },\n  components: {\n    CheckIcon,\n    XIcon,\n    ChevronDownIcon,\n    Combobox,\n    ComboboxButton,\n    ComboboxInput,\n    ComboboxLabel,\n    ComboboxOption,\n    ComboboxOptions,\n    UserCircleIcon,\n    Image,\n  },\n  computed: {\n    filteredContributors(): ContributorListOption[] {\n      return [\n        ...(this.query.length > 0\n          ? [\n              {\n                id: null,\n                avatar_url: null,\n                realname: `${this.__('Create: ', 'podlove-podcasting-plugin-for-wordpress')}${this.query}`,\n              },\n            ]\n          : []),\n        ...this.state.contributors\n          .filter(\n            (contributor) =>\n              !this.state.episodeContributions.some(\n                (episodeContribution) =>\n                  episodeContribution.contributor_id &&\n                  episodeContribution.contributor_id.toString() === contributor.id.toString()\n              )\n          )\n          .filter(\n            (contributor) =>\n              !this.query ||\n              getContributorName(contributor).toUpperCase().includes(this.query.toUpperCase())\n          ),\n      ]\n    },\n  },\n  watch: {\n    data(value) {\n      if (value.id) {\n        this.$emit('addContributor', value)\n      } else {\n        this.$emit('createContributor', this.query)\n      }\n    },\n  },\n  methods: {\n    contributorName(contributor: ContributorListOption) {\n      return getContributorName(contributor)\n    },\n    filterContributors(event: Event) {\n      this.query = get(event, ['target', 'value'], '')\n    },\n    close() {\n      this.$emit('close')\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/contributors/components/Contribution.vue",
    "content": "<template>\n  <div class=\"block hover:bg-gray-50\">\n    <div class=\"flex items-center px-4 py-4 sm:px-6\">\n      <div class=\"flex min-w-0 flex-1 items-center\">\n        <div class=\"flex-shrink-0\">\n          <img\n            v-if=\"data.avatar_url\"\n            class=\"h-12 w-12 rounded-full\"\n            :src=\"data.avatar_url\"\n            :alt=\"data.name\"\n            @error=\"data.avatar_url = ''\"\n          />\n          <UserCircleIcon\n            v-if=\"!data.avatar_url\"\n            class=\"h-12 w-12 flex-shrink-0 rounded-full text-gray-500\"\n          />\n        </div>\n        <div class=\"min-w-0 flex-1 px-2 md:grid md:gap-4\">\n          <div class=\"flex-shrink-0\">\n            <p class=\"truncate text-sm font-medium text-gray-900\">\n              {{ data.realname || data.publicname }}\n            </p>\n            <p class=\"flex items-center text-sm text-gray-500\">\n              <span class=\"truncate\">{{ data.nickname }}</span>\n            </p>\n          </div>\n        </div>\n        <div class=\"min-w-0 flex-1 px-4 md:grid md:gap-4\">\n          <div>\n            <label for=\"location\" class=\"block text-sm font-medium leading-6 text-gray-900\">{{\n              __('Role', 'podlove-podcasting-plugin-for-wordpress')\n            }}</label>\n            <select\n              class=\"mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6\"\n              @change=\"($event) => updateRole($event)\"\n            >\n              <option\n                v-for=\"role in state.roles\"\n                :value=\"role.id\"\n                :selected=\"role.id === data.role_id\"\n              >\n                {{ role.title }}\n              </option>\n            </select>\n          </div>\n        </div>\n        <div class=\"min-w-0 flex-1 px-4 md:grid md:gap-4\">\n          <div>\n            <label for=\"location\" class=\"block text-sm font-medium leading-6 text-gray-900\">{{\n              __('Group', 'podlove-podcasting-plugin-for-wordpress')\n            }}</label>\n            <select\n              class=\"mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6\"\n              @change=\"($event) => updateGroup($event)\"\n            >\n              <option\n                v-for=\"group in state.groups\"\n                :value=\"group.id\"\n                :selected=\"group.id === data.group_id\"\n              >\n                {{ group.title }}\n              </option>\n            </select>\n          </div>\n        </div>\n        <div class=\"min-w-0 flex-1 px-4 md:grid md:gap-4\">\n          <div>\n            <label for=\"email\" class=\"block text-sm font-medium leading-6 text-gray-900\">{{\n              __('Comment', 'podlove-podcasting-plugin-for-wordpress')\n            }}</label>\n            <div class=\"mt-2\">\n              <input\n                type=\"text\"\n                :value=\"data.comment\"\n                @input=\"updateComment($event)\"\n                class=\"block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6\"\n                :placeholder=\"__('Comment', 'podlove-podcasting-plugin-for-wordpress')\"\n              />\n            </div>\n          </div>\n        </div>\n        <div class=\"flex space-x-2 justify-end mt-[30px]\">\n          <button\n            @click=\"moveContributionUp()\"\n            :disabled=\"first\"\n            :class=\"{\n              'text-indigo-600': !first,\n              'text-gray-500': first,\n            }\"\n          >\n            <arrow-up-icon class=\"w-5 h-5\" />\n          </button>\n          <button\n            @click=\"moveContributionDown()\"\n            :disabled=\"last\"\n            :class=\"{\n              'text-indigo-600': !last,\n              'text-gray-500': last,\n            }\"\n          >\n            <arrow-down-icon class=\"w-5 h-5\" />\n          </button>\n          <a :href=\"editLink\" target=\"_blank\" class=\"text-gray-400\">\n            <pencil-icon class=\"w-5 h-5\" />\n          </a>\n          <button class=\"text-red-600\" @click=\"deleteContribution()\">\n            <x-icon class=\"w-5 h-5\" />\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport * as episode from '@store/episode.store'\nimport { PodloveContributor, PodloveRole, PodloveGroup } from '../../../types/contributors.types'\nimport { PodloveEpisodeContribution } from '../../../types/episode.types'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport {\n  ArrowUpIcon,\n  ArrowDownIcon,\n  XMarkIcon as XIcon,\n  UserCircleIcon,\n  PencilIcon,\n} from '@heroicons/vue/24/outline'\n\nimport { get } from 'lodash'\n\ntype ContributionViewModel = PodloveEpisodeContribution & Partial<PodloveContributor> & {\n  id: string | number | null\n}\n\nexport default defineComponent({\n  components: {\n    ArrowUpIcon,\n    ArrowDownIcon,\n    XIcon,\n    UserCircleIcon,\n    PencilIcon,\n  },\n  props: {\n    data: {\n      type: Object,\n      default: () => ({\n        id: null,\n        contributor_id: null,\n        role_id: null,\n        group_id: null,\n        position: null,\n        comment: null,\n        identifier: null,\n        avatar: null,\n        name: null,\n        mail: null,\n        department: null,\n        organisation: null,\n        jobtitle: null,\n        gender: null,\n        nickname: null,\n        count: null,\n      }),\n    },\n    first: {\n      type: Boolean,\n      default: false,\n    },\n    last: {\n      type: Boolean,\n      default: false,\n    },\n  },\n\n  setup(): {\n    dispatch: Function\n    state: {\n      roles: PodloveRole[]\n      groups: PodloveGroup[]\n      baseUrl: string | null\n    }\n  } {\n    return {\n      dispatch: injectAppDispatch(),\n      state: mapAppState({\n        roles: selectors.contributors.roles,\n        groups: selectors.contributors.groups,\n        baseUrl: selectors.runtime.baseUrl,\n      }),\n    }\n  },\n\n  computed: {\n    editLink() {\n      return `${this.state.baseUrl || ''}/wp-admin/admin.php?page=podlove_contributor_settings&action=edit&contributor=${this.data.contributor_id}`\n    },\n  },\n\n  methods: {\n    moveContributionUp() {\n      this.dispatch(episode.moveContributionUp(this.data as PodloveEpisodeContribution))\n    },\n    moveContributionDown() {\n      this.dispatch(episode.moveContributionDown(this.data as PodloveEpisodeContribution))\n    },\n    deleteContribution() {\n      this.dispatch(episode.deleteContribution(this.data as PodloveEpisodeContribution))\n    },\n    updateRole(event: Event) {\n      const role_id = get(event, ['target', 'value'])\n\n      this.dispatch(\n        episode.updateContribution({\n          ...(this.data as PodloveEpisodeContribution),\n          role_id,\n        })\n      )\n    },\n    updateGroup(event: Event) {\n      const group_id = get(event, ['target', 'value'])\n      console.log(group_id)\n      this.dispatch(\n        episode.updateContribution({\n          ...(this.data as PodloveEpisodeContribution),\n          group_id,\n        })\n      )\n    },\n    updateComment(event: Event) {\n      const comment = get(event, ['target', 'value'])\n\n      this.dispatch(\n        episode.updateContribution({\n          ...(this.data as PodloveEpisodeContribution),\n          comment,\n        })\n      )\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/contributors/index.ts",
    "content": "import Contributors from './Contributors.vue';\n\nexport default Contributors;\n"
  },
  {
    "path": "client/src/modules/description/Description.vue",
    "content": "<template>\n  <module name=\"description\" :title=\"__('Episode Description', 'podlove-podcasting-plugin-for-wordpress')\">\n    <div class=\"p-3\">\n      <div class=\"flex justify-items-stretch mb-5\">\n        <episode-poster class=\"mr-5\"/>\n        <div class=\"mb-2 w-full\">\n          <div class=\"flex mb-5\">\n            <episode-number class=\"w-full\" />\n            <episode-content v-if=\"state.explicitContentEnabled\" class=\"ml-5 w-full\" />\n          </div>\n          <episode-type class=\"w-full\" />\n        </div>\n      </div>\n\n      <div class=\"mb-5\">\n        <episode-title class=\"w-full\" />\n      </div>\n      <div class=\"mb-5\">\n        <episode-subtitle class=\"w-full\" />\n      </div>\n      <div>\n        <episode-summary class=\"w-full\" />\n      </div>\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport Module from '@components/module/Module.vue'\nimport * as episode from '@store/episode.store'\n\nimport EpisodePoster from './components/EpisodePoster.vue'\nimport EpisodeNumber from './components/EpisodeNumber.vue'\nimport EpisodeContent from './components/EpisodeContent.vue'\nimport EpisodeTitle from './components/EpisodeTitle.vue'\nimport EpisodeSubtitle from './components/EpisodeSubtitle.vue'\nimport EpisodeSummary from './components/EpisodeSummary.vue'\nimport EpisodeType from './components/EpisodeType.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    EpisodePoster,\n    EpisodeNumber,\n    EpisodeContent,\n    EpisodeTitle,\n    EpisodeSubtitle,\n    EpisodeSummary,\n    EpisodeType\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        explicitContentEnabled: selectors.settings.enableEpisodeExplicit,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  created() {\n    this.dispatch(episode.init())\n  },\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeContent.vue",
    "content": "<template>\n  <div class=\"relative flex items-start\">\n    <div class=\"flex items-center h-5\">\n      <input\n        id=\"explicit-content\"\n        type=\"checkbox\"\n        class=\"focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded\"\n        :checked=\"!!state.explicit\"\n        @input=\"updateExplicit($event)\"\n      />\n    </div>\n    <div class=\"ml-3 text-sm\">\n      <label for=\"explicit-content\" class=\"font-medium text-gray-700\">{{ state.explicit ? __('Explicit Content!', 'podlove-podcasting-plugin-for-wordpress') : __('Explicit Content?', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n      <p class=\"text-gray-500\">\n        {{ __('For example, profanity or content that may not be suitable for children', 'podlove-podcasting-plugin-for-wordpress') }}\n      </p>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  setup() {\n    return {\n      state: mapAppState({\n        explicit: selectors.episode.explicit,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateExplicit(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'explicit', value: (event.target as HTMLInputElement).checked })\n      )\n    },\n  },\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeNumber.vue",
    "content": "<template>\n  <div>\n    <label for=\"episode-number\" class=\"block text-sm font-medium text-gray-700\">{{ __('Number', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n    <div class=\"mt-1 mb-1\">\n      <input\n        name=\"episode-number\"\n        type=\"number\"\n        class=\"\n          shadow-sm\n          focus:ring-indigo-500 focus:border-indigo-500\n          block\n          w-full\n          sm:text-sm\n          border-gray-300\n          rounded-md\n        \"\n        :value=\"state.number\"\n        @input=\"updateNumber($event)\"\n      />\n    </div>\n   <p class=\"mt-2 text-sm text-gray-500\">{{ __('An episode number (1, 2, 3 etc.)', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\n\nimport Module from '@components/module/Module.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        number: selectors.episode.number,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateNumber(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'number', value: (event.target as HTMLInputElement).value })\n      )\n    },\n  },\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodePoster.vue",
    "content": "<template>\n  <div>\n    <modal size=\"medium\" :open=\"modalOpen\" @close=\"closeModal()\">\n      <div class=\"border-gray-200 border-b pb-2 px-4 -mx-6 mb-4\">\n        <h3 class=\"text-lg leading-6 font-medium text-gray-900\">\n          {{ __('Episode Poster', 'podlove-podcasting-plugin-for-wordpress') }}\n        </h3>\n      </div>\n      <div class=\"relative\">\n        <input\n          name=\"episode-poster\"\n          type=\"text\"\n          class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md pr-8\"\n          :value=\"state.episodePoster\"\n          @change=\"updatePoster($event)\"\n        />\n        <button\n          class=\"absolute right-2 top-1/2 -mt-3 text-gray-400 hover:text-gray-700\"\n          :title=\"__('Clear Input', 'podlove-podcasting-plugin-for-wordpress')\"\n          @click=\"updatePoster(null)\"\n        >\n          <x-icon class=\"w-6 h-6\" />\n        </button>\n      </div>\n      <p class=\"mt-2 text-sm text-gray-500\">\n        {{\n          __(\n            'Enter URL or select image from media library. Apple/iTunes recommends 3000 x 3000 pixel JPG or PNG',\n            'podlove-podcasting-plugin-for-wordpress'\n          )\n        }}\n      </p>\n      <p class=\"mt-2 flex justify-end\">\n        <podlove-button variant=\"primary\" @click=\"closeModal()\">{{\n          __('Use this URL', 'podlove-podcasting-plugin-for-wordpress')\n        }}</podlove-button>\n      </p>\n    </modal>\n\n    <label class=\"block text-sm font-medium text-gray-700\">{{\n      __('Poster', 'podlove-podcasting-plugin-for-wordpress')\n    }}</label>\n    <div\n      class=\"border shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block relative w-44 h-44 sm:text-sm border-gray-300 rounded-md mt-1 overflow-hidden bg-cover bg-no-repeat bg-center\"\n      :style=\"posterStyle\"\n    >\n      <div\n        class=\"absolute z-10 left-0 top-0 w-full h-full flex justify-center items-center bg-white bg-opacity-40 hover:opacity-0 text-gray-500 opacity-100\"\n        v-if=\"state.asset === 'manual'\"\n      >\n        <pencil-icon class=\"h-6 w-6\" aria-hidden=\"true\" />\n      </div>\n      <div\n        class=\"absolute z-10 left-0 top-0 w-full h-full flex flex-col justify-center items-center bg-white bg-opacity-50 text-gray-500 opacity-0 hover:opacity-100\"\n        v-if=\"state.asset === 'manual'\"\n      >\n        <podlove-button @click=\"openModal()\" class=\"w-32 mb-2\"\n          ><span class=\"w-full text-center\">{{\n            __('URL', 'podlove-podcasting-plugin-for-wordpress')\n          }}</span></podlove-button\n        >\n        <podlove-button class=\"w-32 text-center mb-2\" @click=\"selectImage()\"\n          ><span class=\"w-full text-center\">{{\n            __('Media', 'podlove-podcasting-plugin-for-wordpress')\n          }}</span></podlove-button\n        >\n        <podlove-button\n          variant=\"danger\"\n          class=\"w-32 text-center\"\n          :disabled=\"state.episodePoster === null\"\n          @click=\"updatePoster(null)\"\n          ><span class=\"w-full text-center\">{{\n            __('Reset', 'podlove-podcasting-plugin-for-wordpress')\n          }}</span></podlove-button\n        >\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { StyleValue, defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { PencilIcon, XMarkIcon as XIcon } from '@heroicons/vue/24/outline'\nimport { update as updateEpisode, selectPoster as selectEpisodePoster } from '@store/episode.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport Modal from '@components/modal/Modal.vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport { get } from 'lodash'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Modal,\n    PencilIcon,\n    XIcon,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        effectivePoster: selectors.episode.effectivePoster,\n        episodePoster: selectors.episode.episodePoster,\n        asset: selectors.settings.imageAsset,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  data() {\n    return {\n      modalOpen: false,\n      inputValue: null,\n    }\n  },\n\n  computed: {\n    posterStyle(): StyleValue {\n      const url = this.state.effectivePoster\n\n      if (url === null) {\n        return {}\n      }\n\n      return { 'background-image': `url(${url})` }\n    },\n  },\n\n  methods: {\n    openModal() {\n      this.modalOpen = true\n    },\n\n    closeModal() {\n      this.modalOpen = false\n    },\n\n    selectImage() {\n      this.dispatch(selectEpisodePoster())\n    },\n\n    updatePoster(event: Event | null) {\n      const value = get(event, ['target', 'value'], null)\n\n      this.dispatch(updateEpisode({ prop: 'episode_poster', value }))\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeSubtitle.vue",
    "content": "<template>\n  <div>\n    <label for=\"subtitle\" class=\"block text-sm font-medium text-gray-700\">{{\n      __('Subtitle', 'podlove-podcasting-plugin-for-wordpress')\n    }}</label>\n    <div class=\"mt-1\">\n      <textarea\n        id=\"subtitle\"\n        name=\"subtitle\"\n        maxlength=\"250\"\n        class=\"\n          shadow-sm\n          focus:ring-indigo-500 focus:border-indigo-500\n          mt-1\n          block\n          w-full\n          sm:text-sm\n          border border-gray-300\n          rounded-md\n          resize-y\n        \"\n        :value=\"state.subtitle\"\n        @input=\"updateSubtitle($event)\"\n      ></textarea>\n    </div>\n    <p class=\"mt-2 text-sm text-gray-500 flex justify-between\">\n      <span>{{ __('Single sentence describing the episode.', 'podlove-podcasting-plugin-for-wordpress') }}</span>\n      <span>{{ charactersLeft }}</span>\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { computed, defineComponent } from 'vue'\n\nimport { update as updateEpisode } from '@store/episode.store'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  setup() {\n    const state = mapAppState({\n      subtitle: selectors.episode.subtitle,\n    })\n\n    const charactersLeft = computed(() => 255 - (state.subtitle?.length || 0))\n\n    return {\n      state,\n      charactersLeft,\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateSubtitle(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'subtitle', value: (event.target as HTMLInputElement).value })\n      )\n    },\n  }\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeSummary.vue",
    "content": "<template>\n  <div>\n    <label for=\"summary\" class=\"block text-sm font-medium text-gray-700\">{{\n      __('Summary', 'podlove-podcasting-plugin-for-wordpress')\n    }}</label>\n    <div class=\"mt-1\">\n      <textarea\n        id=\"summary\"\n        name=\"summary\"\n        maxlength=\"4000\"\n        class=\"\n          shadow-sm\n          focus:ring-indigo-500 focus:border-indigo-500\n          mt-1\n          block\n          w-full\n          sm:text-sm\n          border border-gray-300\n          rounded-md\n          resize-y\n        \"\n        :value=\"state.summary\"\n        @input=\"updateSummary($event)\"\n      ></textarea>\n    </div>\n    <p class=\"mt-2 text-sm text-gray-500 flex justify-end\">\n      <span>{{ charactersLeft }}</span>\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { computed, defineComponent } from 'vue'\n\nimport { update as updateEpisode } from '@store/episode.store'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  setup() {\n    const state = mapAppState({\n      summary: selectors.episode.summary,\n    })\n\n    const charactersLeft = computed(() => 4000 - (state.summary?.length || 0))\n\n    return {\n      state,\n      charactersLeft,\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateSummary(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'summary', value: (event.target as HTMLInputElement).value })\n      )\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeTitle.vue",
    "content": "<template>\n  <div>\n    <label for=\"episode-title\" class=\"block text-sm font-medium text-gray-700\">{{ __('Title', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n    <div class=\"mt-1\">\n      <input\n        name=\"episode-title\"\n        type=\"text\"\n        class=\"\n          shadow-sm\n          focus:ring-indigo-500 focus:border-indigo-500\n          block\n          w-full\n          sm:text-sm\n          border-gray-300\n          rounded-md\n        \"\n        :value=\"state.title\"\n        :placeholder=\"state.post_title || undefined\"\n        @input=\"updateTitle($event)\"\n      />\n    </div>\n    <p class=\"mt-2 text-sm text-gray-500\">{{ __('Clear, concise name for your episode. It is recommended to not include the podcast title, episode number, season number or date in this tag.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport Module from '@components/module/Module.vue'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        title: selectors.episode.title,\n        post_title: selectors.post.title\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateTitle(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'title', value: (event.target as HTMLInputElement).value })\n      )\n    },\n  },\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/description/components/EpisodeType.vue",
    "content": "<template>\n  <div>\n    <label for=\"episode-type\" class=\"block text-sm font-medium text-gray-700\">{{\n      __('Type', 'podlove-podcasting-plugin-for-wordpress')\n    }}</label>\n    <div class=\"mt-1\">\n      <select\n        name=\"episode-type\"\n        class=\"\n          block\n          w-full\n          pl-3\n          pr-10\n          py-2\n          text-base\n          border-gray-300\n          focus:outline-none focus:ring-indigo-500 focus:border-indigo-500\n          sm:text-sm\n          rounded-md\n        \"\n        :value=\"state.type\"\n        @input=\"updateType($event)\"\n      >\n        <option>{{ __('Please choose ...', 'podlove-podcasting-plugin-for-wordpress') }}</option>\n        <option value=\"full\">{{ __('full (complete content of an episode)', 'podlove-podcasting-plugin-for-wordpress') }}</option>\n        <option value=\"trailer\">{{ __('trailer (short, promotional piece of content that represents a preview of an episode)', 'podlove-podcasting-plugin-for-wordpress') }}</option>\n        <option value=\"bonus\">{{ __('bonus (extra content for an episode, for example behind the scenes information)', 'podlove-podcasting-plugin-for-wordpress') }}</option>\n      </select>\n    </div>\n    <p class=\"mt-2 text-sm text-gray-500\">\n      {{ __('Episode type. May be used by podcast clients.', 'podlove-podcasting-plugin-for-wordpress') }}\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport Module from '@components/module/Module.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        type: selectors.episode.type,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateType(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'type', value: (event.target as HTMLInputElement).value })\n      )\n    },\n  },\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/description/index.ts",
    "content": "import Description from './Description.vue';\n\nexport default Description;\n"
  },
  {
    "path": "client/src/modules/index.ts",
    "content": "import PodloveDescription from './description'\nimport PodloveChapters from './chapters'\nimport PodloveTranscripts from './transcripts'\nimport PodloveAuphonic from './auphonic'\nimport PodloveContributors from './contributors'\nimport PodloveMediaFiles from './mediafiles'\nimport PodlovePlusFileMigration from './plus_file_migration'\nimport PodlovePlusFeatures from './plus_features'\nimport PodlovePlusToken from './plus_token'\nimport PodloveRelatedEpisodes from './related'\nimport PodloveSoundbite from './soundbite'\nimport PodloveShowSelect from './shows'\nimport PodloveLicense from './license'\n\nexport default {\n  PodloveDescription,\n  PodloveChapters,\n  PodloveTranscripts,\n  PodloveAuphonic,\n  PodloveContributors,\n  PodloveMediaFiles,\n  PodloveRelatedEpisodes,\n  PodlovePlusFileMigration,\n  PodlovePlusFeatures,\n  PodlovePlusToken,\n  PodloveSoundbite,\n  PodloveShowSelect,\n  PodloveLicense,\n}\n"
  },
  {
    "path": "client/src/modules/license/License.vue",
    "content": "<template>\n  <module name=\"license\" :title=getModuleTitle>\n    <template v-slot:actions>\n      <div v-if=\"isScopeEpisode\">\n        <LicenseSelectorButton :scope=\"scope\"></LicenseSelectorButton>\n      </div>\n    </template>\n    <div class=\"p-3\">\n      <div class=\"mb-3\">\n        <LicenseName :scope=\"scope\"></LicenseName>\n      </div>\n      <div class=\"mb-3\">\n        <LicenseUrl :scope=\"scope\"></LicenseUrl>\n      </div>\n      <div v-if=\"!isScopeEpisode\">\n        <LicenseSelector :scope=\"scope\"></LicenseSelector>\n      </div>\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { PropType, defineComponent } from 'vue'\n\nimport Module from '@components/module/Module.vue'\n\nimport LicenseName from './components/LicenseName.vue'\nimport LicenseUrl from './components/LicenseUrl.vue'\nimport LicenseSelector from './components/LicenseSelector.vue'\nimport LicenseSelectorButton from './components/LicenseSelectorButton.vue'\n\nimport { PodloveLicenseScope } from '../../types/license.types'\n\nexport default defineComponent({\n  components: {\n    Module,\n    LicenseName,\n    LicenseUrl,\n    LicenseSelector,\n    LicenseSelectorButton\n  },\n  props: {\n    scope: {\n      type: String as PropType<PodloveLicenseScope>,\n      default: PodloveLicenseScope.Episode\n    }\n  },\n  computed: {\n    isScopeEpisode(): boolean {\n      return this.scope === PodloveLicenseScope.Episode ? true : false\n    },\n    getModuleTitle(): string {\n      return this.scope === PodloveLicenseScope.Episode ? \"Episode License\" : \"Podcast License\"\n    }\n  }\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/license/components/LicenseName.vue",
    "content": "<template>\n    <div>\n      <label for=\"episode-license-name\" class=\"block text-sm font-medium text-gray-700\">{{ __('License name', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n      <div class=\"mt-1\">\n        <input\n          name=\"episode-license-name\"\n          type=\"text\"\n          :value=\"getLicenseName\"\n          @input=\"updateLicenseName\"\n          class=\"\n            shadow-sm\n            focus:ring-indigo-500 focus:border-indigo-500\n            block\n            w-full\n            sm:text-sm\n            border-gray-300\n            rounded-md\n          \"\n        />\n      </div>\n    </div>\n  </template>\n\n<script lang=\"ts\">\nimport { PropType, defineComponent } from 'vue'\nimport { selectors } from '@store';\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport Module from '@components/module/Module.vue'\nimport * as episode from '@store/episode.store'\nimport * as podcast from '@store/podcast.store'\n\nimport { PodloveLicenseScope } from '../../../types/license.types';\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n  props: {\n    scope: {\n      type: String as PropType<PodloveLicenseScope>,\n      default: PodloveLicenseScope.Episode\n    }\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        episodeLicenseName: selectors.episode.license_name,\n        podcastLicenseName: selectors.podcast.license_name,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n  created() {\n    this.dispatch(episode.init())\n  },\n  computed: {\n    getLicenseName() : string {\n      return this.scope == PodloveLicenseScope.Episode\n        ? this.state.episodeLicenseName || ''\n        : this.state.podcastLicenseName || ''\n    }\n  },\n  methods: {\n    updateLicenseName(event: Event) {\n      if (this.scope === PodloveLicenseScope.Episode) {\n        this.dispatch(\n          episode.update({prop: 'license_name', value: (event.target as HTMLInputElement).value})\n        )\n      }\n      if (this.scope === PodloveLicenseScope.Podcast) {\n        this.dispatch(\n          podcast.update({prop: 'license_name', value: (event.target as HTMLInputElement).value})\n        )\n      }\n    }\n  }\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/license/components/LicenseSelector.vue",
    "content": "<template>\n  <div class=\"border-gray-200 border-b pb-2 px-3 py-5\">\n    <h3 class=\"text-lg leading-6 font-medium text-gray-900\">{{ __('License Selector', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n  </div>\n  <div>\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium text-gray-700\">\n        Version:\n      </label>\n      <select :value=\"getLicenseData().version\" @input=\"updateVersion($event)\" class=\"\n            mt-1\n            block\n            w-full\n            py-2\n            px-3\n            border border-gray-300\n            bg-white\n            rounded-md\n            shadow-sm\n            focus:outline-none focus:ring-indigo-500 focus:border-indigo-500\n            sm:text-sm\n          \">\n        <option v-for=\"(version, vindex) in PodloveLicenseVersionList\" :value=\"version.value\" :key=\"`version-${vindex}`\">\n          {{ version.value }}\n        </option>\n      </select>\n    </div>\n    <div v-if=\"isCommercialNModificationNeeded\" class=\"mb-3\">\n      <label class=\"block text-sm font-medium text-gray-700\">\n        Allow modifications of your work?\n      </label>\n      <select :value=\"getLicenseData().optionModification\" @input=\"updateModification($event)\" class=\"\n            mt-1\n            block\n            w-full\n            py-2\n            px-3\n            border border-gray-300\n            bg-white\n            rounded-md\n            shadow-sm\n            focus:outline-none focus:ring-indigo-500 focus:border-indigo-500\n            sm:text-sm\n          \">\n        <option v-for=\"(modification, mindex) in PodloveLicenseOptionModificationList\" :value=\"modification.value\"\n          :key=\"`modification-${mindex}`\">\n          {{ modification.value }}\n        </option>\n      </select>\n    </div>\n    <div v-if=\"isCommercialNModificationNeeded\" class=\"mb-3\">\n      <label class=\"block text-sm font-medium text-gray-700\">\n        Allow commercial uses of your work?\n      </label>\n      <select :value=\"getLicenseData().optionCommercial\" @input=\"updateCommercial($event)\" class=\"\n            mt-1\n            block\n            w-full\n            py-2\n            px-3\n            border border-gray-300\n            bg-white\n            rounded-md\n            shadow-sm\n            focus:outline-none focus:ring-indigo-500 focus:border-indigo-500\n            sm:text-sm\n          \">\n        <option v-for=\"(commercial, cindex) in PodloveLicenseOptionCommercialList\" :value=\"commercial.value\"\n          :key=\"`commercial-${cindex}`\">\n          {{ commercial.value }}\n        </option>\n      </select>\n    </div>\n    <div v-if=\"isJurisdicationNeeded\" class=\"mb-3\">\n      <label class=\"block text-sm font-medium text-gray-700\">\n        License Jurisdiction\n      </label>\n      <select :value=\"getLicenseData().optionJurisdication?.name\" @input=\"updateJurisdication($event)\" class=\"\n            mt-1\n            block\n            w-full\n            py-2\n            px-3\n            border border-gray-300\n            bg-white\n            rounded-md\n            shadow-sm\n            focus:outline-none focus:ring-indigo-500 focus:border-indigo-500\n            sm:text-sm\n          \">\n        <option v-for=\"(jurisdication, jindex) in PodloveLicenseOptionJurisdication\" :value=\"jurisdication.name\"\n          :key=\"`jurisdiction-${jindex}`\">\n          {{ jurisdication.name }}\n        </option>\n      </select>\n    </div>\n    <div class=\"mb-5\">\n      <LicensePreview :license-data=\"getLicenseData()\"></LicensePreview>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { PropType, defineComponent } from '@vue/runtime-core'\n\nimport { update as updateEpisode } from '@store/episode.store'\nimport { update as updatePodcast } from '@store/podcast.store'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport LicensePreview from './LicenseView.vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport Modal from '@components/modal/Modal.vue'\n\nimport { PodloveLicense, PodloveLicenseVersion, PodloveLicenseOptionCommercial,\n  PodloveLicenseOptionModification, PodloveLicenseOptionJurisdication, PodloveLicenseScope } from '../../../types/license.types'\nimport { getLicenseFromUrl, getLicenseUrl } from '@lib/license'\n\nconst PodloveLicenseVersionList: {\n  key: string;\n  value: string;\n}[] = Object.entries(PodloveLicenseVersion).map(([key, value]) => ({ key, value }));\n\nconst PodloveLicenseOptionCommercialList: {\n  key: string;\n  value: string;\n}[] = Object.entries(PodloveLicenseOptionCommercial).map(([key, value]) => ({ key, value }));\n\nconst PodloveLicenseOptionModificationList: {\n  key: string;\n  value: string;\n}[] = Object.entries(PodloveLicenseOptionModification).map(([key, value]) => ({ key, value }));\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Modal,\n    LicensePreview,\n  },\n  data() {\n    return {\n      PodloveLicenseVersionList,\n      PodloveLicenseOptionCommercialList,\n      PodloveLicenseOptionModificationList,\n      PodloveLicenseOptionJurisdication\n    }\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        episodeLicenseUrl: selectors.episode.license_url,\n        podcastLicenseUrl: selectors.podcast.license_url\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n  props: {\n    scope: {\n      type: String as PropType<PodloveLicenseScope>,\n      default: PodloveLicenseScope.Episode\n    }\n  },\n  computed: {\n    isCommercialNModificationNeeded() : boolean {\n      if (this.getLicenseData().type === \"cc\" && (this.getLicenseData().version === PodloveLicenseVersion.cc3 || this.getLicenseData().version === PodloveLicenseVersion.cc4))\n        return true\n      return false\n    },\n    isJurisdicationNeeded() : boolean {\n      if (this.getLicenseData().type === \"cc\" && this.getLicenseData().version === PodloveLicenseVersion.cc3)\n        return true;\n      return false;\n    },\n  },\n  methods: {\n    updateVersion(event: Event) {\n      let licenseData = this.getLicenseData()\n      licenseData.type = \"cc\"\n      const value : string = (event.target as HTMLInputElement).value\n      switch (value) {\n        case PodloveLicenseVersion.cc0:\n          licenseData.version = PodloveLicenseVersion.cc0\n          break;\n        case PodloveLicenseVersion.pdmark:\n          licenseData.version = PodloveLicenseVersion.pdmark\n          break;\n        case PodloveLicenseVersion.cc3:\n          licenseData.version = PodloveLicenseVersion.cc3\n          break;\n        case PodloveLicenseVersion.cc4:\n          licenseData.version = PodloveLicenseVersion.cc4\n          break;\n      }\n      this.updateLicenseDataNameNUrl(licenseData)\n    },\n    updateCommercial(event: Event) {\n      let licenseData = this.getLicenseData()\n      const value : string = (event.target as HTMLInputElement).value\n      switch (value) {\n        case PodloveLicenseOptionCommercial.yes:\n          licenseData.optionCommercial = PodloveLicenseOptionCommercial.yes\n          break;\n        case PodloveLicenseOptionCommercial.no:\n          licenseData.optionCommercial = PodloveLicenseOptionCommercial.no\n          break;\n      }\n      this.updateLicenseDataUrl(licenseData)\n    },\n    updateModification(event: Event) {\n      let licenseData = this.getLicenseData()\n      const value : string = (event.target as HTMLInputElement).value\n      switch (value) {\n        case PodloveLicenseOptionModification.yes:\n          licenseData.optionModification = PodloveLicenseOptionModification.yes\n          break;\n          case PodloveLicenseOptionModification.yesbutshare:\n          licenseData.optionModification = PodloveLicenseOptionModification.yesbutshare\n          break;\n        case PodloveLicenseOptionModification.no:\n          licenseData.optionModification = PodloveLicenseOptionModification.no\n          break;\n      }\n      this.updateLicenseDataUrl(licenseData)\n    },\n    updateJurisdication(event: Event) {\n      let licenseData = this.getLicenseData()\n      const value : string = (event.target as HTMLInputElement).value\n      const idx: number = PodloveLicenseOptionJurisdication.findIndex(item => item.name === value)\n\n      if (idx !== undefined) {\n        licenseData.optionJurisdication = PodloveLicenseOptionJurisdication[idx]\n        this.updateLicenseDataUrl(licenseData)\n      }\n    },\n    updateLicenseDataUrl(licenseData: PodloveLicense) {\n      if (this.scope === PodloveLicenseScope.Episode) {\n          this.dispatch(\n            updateEpisode({ prop: 'license_url', value: getLicenseUrl(licenseData) })\n          )\n        }\n        if (this.scope === PodloveLicenseScope.Podcast) {\n          this.dispatch(\n            updatePodcast({ prop: 'license_url', value: getLicenseUrl(licenseData) })\n          )\n        }\n    },\n    updateLicenseDataNameNUrl(licenseData: PodloveLicense) {\n      if (this.scope === PodloveLicenseScope.Episode) {\n          this.dispatch(\n            updateEpisode({ prop: 'license_name', value: licenseData.version })\n          )\n          this.dispatch(\n            updateEpisode({ prop: 'license_url', value: getLicenseUrl(licenseData) })\n          )\n        }\n        if (this.scope === PodloveLicenseScope.Podcast) {\n          this.dispatch(\n            updatePodcast({ prop: 'license_name', value: licenseData.version })\n          )\n          this.dispatch(\n            updatePodcast({ prop: 'license_url', value: getLicenseUrl(licenseData) })\n          )\n        }\n    },\n    getLicenseData() : PodloveLicense {\n      if (this.scope === PodloveLicenseScope.Episode) {\n        if (this.state.episodeLicenseUrl === null || this.state.episodeLicenseUrl === undefined) {\n          return {\n            type: \"cc\",\n            version: PodloveLicenseVersion.pdmark,\n            optionCommercial: null,\n            optionModification: null,\n            optionJurisdication: null\n          } as PodloveLicense\n        }\n        return getLicenseFromUrl(this.state.episodeLicenseUrl)\n      }\n      if (this.state.podcastLicenseUrl === null || this.state.podcastLicenseUrl === undefined) {\n        return {\n          type: \"cc\",\n          version: PodloveLicenseVersion.pdmark,\n          optionCommercial: null,\n          optionModification: null,\n          optionJurisdication: null\n        } as PodloveLicense\n      }\n      return getLicenseFromUrl(this.state.podcastLicenseUrl)\n    }\n  },\n})\n\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/license/components/LicenseSelectorButton.vue",
    "content": "<template>\n  <podlove-button variant=\"secondary\" size=\"small\" @click=\"openSelector\">{{ __('License Selector', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n  <modal :open=\"openDialog\" @close=\"closeSelector\">\n    <LicenseSelector :scope=\"scope\"></LicenseSelector>\n  </modal>\n\n</template>\n\n<script lang=\"ts\">\nimport { PropType, defineComponent } from '@vue/runtime-core'\n\nimport PodloveButton from '@components/button/Button.vue'\nimport Modal from '@components/modal/Modal.vue'\n\nimport LicenseSelector from './LicenseSelector.vue'\nimport { PodloveLicenseScope } from '../../../types/license.types';\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Modal,\n    LicenseSelector,\n  },\n  props: {\n    scope: {\n      type: String as PropType<PodloveLicenseScope>,\n      default: PodloveLicenseScope.Episode\n    }\n  },\n  data() {\n    return {\n      openDialog: false\n    }\n  },\n  methods: {\n    openSelector() {\n      this.openDialog = true\n    },\n    closeSelector() {\n      this.openDialog = false\n    },\n  }\n})\n\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/license/components/LicenseUrl.vue",
    "content": "<template>\n  <div>\n    <label for=\"episode-license-name\" class=\"block text-sm font-medium text-gray-700\">{{ __('License url', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n    <div class=\"mt-1\">\n      <input\n        name=\"episode-license-name\"\n        type=\"text\"\n        :value=\"getLicenseUrl\"\n        @input=\"updateLicenseUrl\"\n        class=\"\n          shadow-sm\n          focus:ring-indigo-500 focus:border-indigo-500\n          block\n          w-full\n          sm:text-sm\n          border-gray-300\n          rounded-md\n        \"\n      />\n    </div>\n    <p class=\"mt-2 text-sm text-gray-500\">{{ __('Example: http://creativecommons.org/licenses/by/3.0/', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { PropType, defineComponent } from 'vue'\nimport { selectors } from '@store';\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport Module from '@components/module/Module.vue'\nimport * as episode from '@store/episode.store'\nimport * as podcast from '@store/podcast.store'\n\nimport { PodloveLicenseScope } from '../../../types/license.types';\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n  props: {\n    scope: {\n      type: String as PropType<PodloveLicenseScope>,\n      default: PodloveLicenseScope.Episode\n    }\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        episodeLicenseUrl: selectors.episode.license_url,\n        podcastLicenseUrl: selectors.podcast.license_url\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n  created() {\n    this.dispatch(episode.init())\n  },\n  computed: {\n    getLicenseUrl() : string {\n      if (this.scope == PodloveLicenseScope.Episode)\n        return this.state.episodeLicenseUrl || ''\n      return this.state.podcastLicenseUrl || ''\n    }\n  },\n  methods: {\n    updateLicenseUrl(event: Event) {\n      if (this.scope === PodloveLicenseScope.Episode) {\n        this.dispatch(\n          episode.update({prop: 'license_url', value: (event.target as HTMLInputElement).value})\n        )\n      }\n      if (this.scope === PodloveLicenseScope.Podcast) {\n        this.dispatch(\n          podcast.update({prop: 'license_url', value: (event.target as HTMLInputElement).value})\n        )\n      }\n    }\n  }\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/license/components/LicenseView.vue",
    "content": "<template>\n  <div class=\"mt-3\">\n    <div class=\"mb-3 text-sm font-medium text-gray-700\">\n      License preview:\n    </div>\n    <div v-if=\"isImageAvailable\">\n      <div>\n        <div class=\"mb-3 w-full\">\n        <div class=\"flex justify-center items-center\">\n          <img class=\"text-center\" :src=\"`${imageUrl}`\"/>\n        </div>\n      </div>\n      <div class=\"mb-3 w-full text-center\">\n        <p class=\"text-sm font-medium text-gray-700\">This work is licensend under </p>\n        <a class=\"text-sm font-medium text-gray-700\"\n          :href=\"`${licenseUrl}`\">{{ licenseUrl }}</a>\n      </div>\n    </div>\n    <div v-if=\"!isImageAvailable\">\n      <p class=\"text-sm font-medium text-gray-700\">No license selected!</p>\n    </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport Module from '@components/module/Module.vue'\nimport { PodloveLicense, PodloveLicenseVersion } from '../../../types/license.types'\nimport { getImageUrl, getLicenseUrl } from '@lib/license'\n\nexport default defineComponent({\n  components: {\n    Module,\n  },\n\n  props: {\n    licenseData: {\n      type: null,\n      default: { \n        type: \"cc\",\n        version: PodloveLicenseVersion.pdmark,\n        optionCommercial: null,\n        optionModification: null,\n        optionJurisdication: null\n      } as PodloveLicense\n    }\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        baseUrl: selectors.runtime.baseUrl,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  computed: {\n    licenseUrl() : string | null {\n      return getLicenseUrl(this.licenseData)\n    },\n    imageUrl() : string | null {\n      return getImageUrl(this.licenseData, this.state.baseUrl || '')\n    },\n    isImageAvailable() : boolean {\n      if (getImageUrl(this.licenseData, this.state.baseUrl || '') !== null)\n        return true\n      return false\n    }\n  }\n\n})\n</script>\n\n\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/license/index.ts",
    "content": "import License from './License.vue';\n\nexport default License;"
  },
  {
    "path": "client/src/modules/mediafiles/MediaFiles.vue",
    "content": "<template>\n  <module name=\"mediafiles\" title=\"Media Files\">\n    <div>\n      <div class=\"w-full flex justify-center m-12 text-center\" v-if=\"isInitializing\">\n        <div class=\"animate-pulse mt-4 flex space-x-4\">\n          <RefreshIcon class=\"animate-spin h-5 w-5 mr-3\" />\n          {{ __('Loading...', 'podlove-podcasting-plugin-for-wordpress') }}\n        </div>\n      </div>\n      <div v-else>\n        <div class=\"px-6\">\n          <div\n            class=\"mt-10 sm:mt-0 space-y-8 border-b border-gray-900/10 pb-12 sm:space-y-0 sm:divide-y sm:divide-gray-900/10 sm:pb-0\"\n          >\n            <div class=\"sm:grid sm:grid-cols-[175px_auto_auto] sm:items-start sm:gap-4 sm:py-6\">\n              <MediaSlug />\n            </div>\n\n            <div\n              v-if=\"isPlusStorageEnabled\"\n              class=\"sm:grid sm:grid-cols-[175px_auto_auto] sm:items-start sm:gap-4 sm:py-6\"\n            >\n              <PlusMediaUpload />\n            </div>\n            <div\n              v-else-if=\"isMediaUploadEnabled\"\n              class=\"sm:grid sm:grid-cols-[175px_auto_auto] sm:items-start sm:gap-4 sm:py-6\"\n            >\n              <MediaUpload />\n            </div>\n\n            <div class=\"sm:grid sm:grid-cols-[175px_auto_auto] sm:items-start sm:gap-4 sm:py-6\">\n              <AssetsTable />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport * as mediafiles from '@store/mediafiles.store'\nimport Module from '@components/module/Module.vue'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport { ArrowPathIcon as RefreshIcon } from '@heroicons/vue/24/outline'\n\nimport MediaSlug from './components/MediaSlug.vue'\nimport MediaUpload from './components/MediaUpload.vue'\nimport PlusMediaUpload from './components/PlusMediaUpload.vue'\nimport AssetsTable from './components/AssetsTable.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    RefreshIcon,\n    MediaSlug,\n    MediaUpload,\n    PlusMediaUpload,\n    AssetsTable,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        isInitializing: selectors.mediafiles.isInitializing,\n        modules: selectors.settings.modules,\n        plusStorageEnabled: selectors.settings.enablePlusStorage,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  computed: {\n    isInitializing(): boolean {\n      return this.state.isInitializing\n    },\n    isMediaUploadEnabled(): boolean {\n      return this.state.modules?.includes('wordpress_file_upload')\n    },\n    isPlusStorageEnabled(): boolean {\n      return !!this.state.plusStorageEnabled\n    },\n  },\n\n  created() {\n    this.dispatch(mediafiles.init())\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/mediafiles/components/AssetsEmptyState.vue",
    "content": "<template>\n  <div class=\"text-center\">\n    <svg\n      class=\"mx-auto h-12 w-12 text-gray-400\"\n      fill=\"none\"\n      viewBox=\"0 0 24 24\"\n      strokeWidth=\"{1.5}\"\n      stroke=\"currentColor\"\n    >\n      <path\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        d=\"M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z\"\n      />\n    </svg>\n\n    <h3 class=\"mt-2 text-sm font-semibold text-gray-900\">{{ __('No Assets', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n    <p class=\"mt-1 text-sm text-gray-500\">\n      {{ __('Please go to Podlove &rarr; Episode Assets and define at least one audio asset.', 'podlove-podcasting-plugin-for-wordpress') }}\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "client/src/modules/mediafiles/components/AssetsTable.vue",
    "content": "<template>\n  <label for=\"assets\" class=\"block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5\">{{\n    __('Assets', 'podlove-podcasting-plugin-for-wordpress')\n  }}</label>\n  <div class=\"mt-2 sm:col-span-2 sm:mt-0\">\n    <div v-if=\"hasFiles\">\n      <table class=\"min-w-full table-fixed divide-y divide-gray-300\">\n        <thead>\n          <tr>\n            <th scope=\"col\" class=\"py-3.5 pl-3 text-left text-sm font-semibold text-gray-900\">\n              {{ __('Enable', 'podlove-podcasting-plugin-for-wordpress') }}\n            </th>\n            <th scope=\"col\" class=\"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900\">\n              {{ __('Asset', 'podlove-podcasting-plugin-for-wordpress') }}\n            </th>\n            <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">\n              {{ __('File', 'podlove-podcasting-plugin-for-wordpress') }}\n            </th>\n            <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\"></th>\n          </tr>\n        </thead>\n        <tbody class=\"divide-y divide-gray-200 bg-white\">\n          <tr v-for=\"file in files\" :key=\"file.asset_id\" :class=\"{ 'opacity-50': !file.enable }\">\n            <td class=\"relative px-7 sm:w-12 sm:px-6\">\n              <div\n                v-if=\"file.is_verifying\"\n                class=\"inline-flex items-center animate-pulse text-green-600 absolute left-[-14px] top-1/2 -mt-2.5\"\n              >\n                <CloudIcon class=\"h-5 w-5\" aria-hidden=\"true\" />\n              </div>\n              <input\n                type=\"checkbox\"\n                class=\"absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600\"\n                :value=\"file.asset_id\"\n                :checked=\"file.enable\"\n                @click=\"handleToggle\"\n              />\n            </td>\n            <td class=\"text-sm\">\n              {{ file.asset }}\n            </td>\n            <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n              <div class=\"flex flex-col\">\n                <a :href=\"file.url\" target=\"_blank\" class=\"text-gray-700 underline\">{{\n                  file.url\n                }}</a>\n                <span class=\"flex\">\n                  <CheckCircleIcon\n                    v-if=\"file.size > 0\"\n                    class=\"mr-1.5 h-5 w-5 flex-shrink-0 text-green-400\"\n                    aria-hidden=\"true\"\n                  />\n                  <XCircleIcon\n                    v-else\n                    class=\"mr-1.5 h-5 w-5 flex-shrink-0\"\n                    :class=\"{\n                      'text-gray-400': !file.enable,\n                      'text-red-400': file.enable,\n                    }\"\n                    aria-hidden=\"true\"\n                  />\n                  <!-- fixme: on load, when an asset is disabled, it shows always \"file not found\"\n(because Publisher does not know about disabled files). Only on \"verify\" do we\nget the actual state. => at least show nothing unless we're sure it's \"file not\nfound\" -->\n                  <span\n                    v-if=\"!file.size\"\n                    :class=\"{\n                      'text-gray-400': !file.enable,\n                      'text-red-400': file.enable,\n                    }\"\n                    >{{ __('File not found', 'podlove-podcasting-plugin-for-wordpress') }}</span\n                  >\n                  <span v-else>{{ fileSize(file) }}</span>\n                </span>\n              </div>\n            </td>\n            <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n              <button\n                type=\"button\"\n                class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-white\"\n                @click=\"() => handleVerify(file.asset_id)\"\n              >\n                {{ __('Verify', 'podlove-podcasting-plugin-for-wordpress') }}\n              </button>\n            </td>\n          </tr>\n        </tbody>\n      </table>\n      <p class=\"mt-3 text-sm leading-6 text-gray-600\">{{ __('Duration:', 'podlove-podcasting-plugin-for-wordpress') }} {{ duration }}</p>\n    </div>\n    <AssetsEmptyState v-else />\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\n\nimport * as mediafiles from '@store/mediafiles.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nimport { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid'\n\nimport { CloudIcon } from '@heroicons/vue/24/outline'\n\nimport Timestamp from '@lib/timestamp'\nimport AssetsEmptyState from './AssetsEmptyState.vue'\n\nexport default defineComponent({\n  components: {\n    CheckCircleIcon,\n    XCircleIcon,\n    CloudIcon,\n    AssetsEmptyState,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        files: selectors.mediafiles.files,\n        duration: selectors.episode.duration,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    handleToggle(event: Event): void {\n      const input = event.target as HTMLInputElement\n      const enable = input.checked\n      const asset_id = parseInt(input.value)\n\n      if (enable) {\n        this.dispatch(mediafiles.enable(asset_id))\n      } else {\n        this.dispatch(mediafiles.disable(asset_id))\n      }\n    },\n    handleVerify(asset_id: number): void {\n      this.dispatch(mediafiles.verify(asset_id))\n    },\n    fileSize(file: mediafiles.MediaFile): string {\n      const bytes = file.size\n\n      if (!bytes || bytes < 1) {\n        return '???'\n      }\n\n      var kilobytes = bytes / 1024\n\n      if (kilobytes < 500) {\n        return kilobytes.toFixed(2) + ' kB'\n      }\n\n      var megabytes = kilobytes / 1024\n      return megabytes.toFixed(2) + ' MB'\n    },\n  },\n\n  computed: {\n    files(): mediafiles.MediaFile[] {\n      return this.state.files\n    },\n    hasFiles(): boolean {\n      return this.files.length > 0\n    },\n    duration(): string {\n      const unknownDuration = '--:--:--.---'\n\n      if (!this.state.duration) {\n        return unknownDuration\n      }\n\n      const timestamp = Timestamp.fromString(this.state.duration)\n\n      if (timestamp.totalMs === 0) {\n        return unknownDuration\n      }\n\n      return timestamp.pretty\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/mediafiles/components/MediaSlug.vue",
    "content": "<template>\n  <label for=\"filename_slug\" class=\"block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5\">{{\n    __('Filename / Slug', 'podlove-podcasting-plugin-for-wordpress')\n  }}</label>\n  <div class=\"mt-2 sm:col-span-2 sm:mt-0\">\n    <!-- Frozen State: Show as read-only text with edit button -->\n    <div\n      v-if=\"state.slugFrozen\"\n      class=\"flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 bg-gray-50\"\n    >\n      <span class=\"flex select-none items-center pl-3 text-gray-500 sm:text-sm\">{{\n        assetPrefix\n      }}</span>\n      <span\n        class=\"block flex-1 py-1.5 pl-1 text-gray-900 sm:text-sm sm:leading-6\"\n      >{{ state.slug }}</span>\n      <span class=\"flex select-none items-center text-gray-500 sm:text-sm pr-2\">.ext</span>\n      <button\n        @click=\"unfreezeSlug\"\n        type=\"button\"\n        class=\"ml-2 px-3 py-1.5 text-sm font-semibold text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 border-l border-gray-300\"\n        :title=\"__('Edit slug', 'podlove-podcasting-plugin-for-wordpress')\"\n        :aria-label=\"__('Edit filename slug', 'podlove-podcasting-plugin-for-wordpress')\"\n        aria-describedby=\"filename_slug_help\"\n      >\n        {{ __('Edit', 'podlove-podcasting-plugin-for-wordpress') }}\n      </button>\n    </div>\n\n    <!-- Unfrozen State: Show as editable input -->\n    <div\n      v-else\n      class=\"flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600\"\n    >\n      <span class=\"flex select-none items-center pl-3 text-gray-500 sm:text-sm\">{{\n        assetPrefix\n      }}</span>\n      <input\n        type=\"text\"\n        name=\"filename_slug\"\n        id=\"filename_slug\"\n        autocomplete=\"filename_slug\"\n        class=\"block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6\"\n        placeholder=\"\"\n        :value=\"state.slug\"\n        @input=\"updateSlug($event)\"\n        :aria-label=\"__('Filename slug', 'podlove-podcasting-plugin-for-wordpress')\"\n        aria-describedby=\"filename_slug_help\"\n      />\n      <span class=\"flex slect-none items-center text-gray-500 sm:text-sm pr-2\">.ext</span>\n    </div>\n\n    <!-- Screen reader help text -->\n    <div id=\"filename_slug_help\" class=\"sr-only\">\n      {{ __('Click Edit to modify the filename slug. The slug determines the final filename of your media files.', 'podlove-podcasting-plugin-for-wordpress') }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { disableSlugAutogen, unfreezeSlug } from '@store/mediafiles.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  setup() {\n    return {\n      state: mapAppState({\n        slug: selectors.episode.slug,\n        slugFrozen: selectors.episode.slugFrozen,\n        id: selectors.episode.id,\n        baseUri: selectors.settings.mediaFileBaseUri,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    updateSlug(event: Event) {\n      this.dispatch(\n        updateEpisode({ prop: 'slug', value: (event.target as HTMLInputElement).value })\n      )\n      // disable slug generation on any manual input\n      this.dispatch(disableSlugAutogen())\n    },\n\n    unfreezeSlug() {\n      this.dispatch(unfreezeSlug())\n    },\n  },\n\n  computed: {\n    assetPrefix(): string {\n      let url = this.state.baseUri?.replace(/https?:\\/\\//i, '').trim()\n\n      if (!url) {\n        return ''\n      }\n\n      const lastSlashPos = url.trim().replace(/\\/+$/g, '').lastIndexOf('/')\n\n      if (url.length > 30 && lastSlashPos > -1) {\n        // only take last subdirectory\n        // very.ultra.longdomain.tld/podcast/ => /podcast/\n        url = url.slice(lastSlashPos)\n      }\n\n      return url\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/mediafiles/components/MediaUpload.vue",
    "content": "<template>\n  <label\n    for=\"mediafile_upload\"\n    class=\"block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5\"\n    >{{ __('Upload', 'podlove-podcasting-plugin-for-wordpress') }}</label\n  >\n  <div class=\"mt-2 sm:col-span-2 sm:mt-0\">\n    <div>\n      <podlove-button variant=\"primary\" @click=\"uploadIntent\" class=\"ml-1\">\n        <upload-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" />\n        {{ __('Upload Media File', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport { uploadIntent } from '@store/mediafiles.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport { CloudArrowUpIcon as UploadIcon } from '@heroicons/vue/24/outline'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    UploadIcon,\n  },\n  setup() {\n    return {\n      state: mapAppState({}),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    uploadIntent() {\n      this.dispatch(uploadIntent())\n    },\n  },\n\n  computed: {},\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/mediafiles/components/PlusMediaUpload.vue",
    "content": "<template>\n  <label\n    for=\"mediafile_upload\"\n    class=\"block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5\"\n    >{{ __('PLUS Upload', 'podlove-podcasting-plugin-for-wordpress') }}</label\n  >\n  <div class=\"mt-2 sm:col-span-2 sm:mt-0\">\n    <div>\n      <label\n        for=\"plus-file-upload\"\n        class=\"relative max-w-[400px] flex flex-col gap-2 cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none\"\n      >\n        <div>\n          <podlove-button v-if=\"!state.selectedFiles || state.selectedFiles.length === 0\" variant=\"primary\" @click.prevent=\"triggerFileInput\">\n            <upload-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" />\n            {{ __('Select Files for Upload', 'podlove-podcasting-plugin-for-wordpress') }}\n          </podlove-button>\n        </div>\n\n        <!-- File Details Area -->\n        <div v-if=\"state.selectedFiles && state.selectedFiles.length > 0\">\n          <div class=\"space-y-3\">\n            <div\n              v-for=\"(fileInfo, index) in state.selectedFiles\"\n              :key=\"fileInfo.newName\"\n              class=\"flex items-start space-x-3 p-3 bg-gray-100 rounded-lg\"\n            >\n              <!-- File Icon -->\n              <div class=\"flex-shrink-0\">\n                <div class=\"w-10 h-10 bg-white rounded-lg flex items-center justify-center\">\n                  <div\n                    v-if=\"getFilenameGenerationStatus(fileInfo.originalName) === 'in_progress'\"\n                    class=\"animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500\"\n                  ></div>\n                  <document-text-icon v-else class=\"w-6 h-6 text-indigo-500\" />\n                </div>\n              </div>\n\n              <!-- File Info -->\n              <div class=\"flex-1 min-w-0\">\n                <p class=\"text-sm font-medium text-gray-900 truncate\">\n                  {{ fileInfo.file.name }}\n                </p>\n                <p v-if=\"fileInfo.originalName !== fileInfo.newName\" class=\"text-xs text-gray-500\">\n                  {{ __('Original name:', 'podlove-podcasting-plugin-for-wordpress') }}\n                  {{ fileInfo.originalName }}\n                </p>\n                <p class=\"text-xs text-gray-500\">\n                  {{ (fileInfo.file.size / 1024 / 1024).toFixed(2) }} MB\n                </p>\n\n                <!-- Filename Generation Status -->\n                <div\n                  v-if=\"getFilenameGenerationStatus(fileInfo.originalName) === 'in_progress'\"\n                  class=\"mt-2 flex items-center text-indigo-600\"\n                >\n                  <div class=\"animate-spin rounded-full h-3 w-3 border-b border-indigo-500 mr-2\"></div>\n                  <span class=\"text-xs\">\n                    {{ __('Generating filename...', 'podlove-podcasting-plugin-for-wordpress') }}\n                  </span>\n                </div>\n\n                <!-- Progress Bar -->\n                <div class=\"mt-2 w-full bg-white rounded-full h-1.5\">\n                  <div\n                    class=\"bg-indigo-600 h-1.5 rounded-full progress-transition\"\n                    :style=\"{ width: (getUploadProgress(fileInfo.file.name) || 0) + '%' }\"\n                  ></div>\n                </div>\n\n                <!-- Progress Status -->\n                <div class=\"flex justify-between items-center mt-1\">\n                  <p class=\"text-xs text-gray-500\">\n                    <span v-if=\"getUploadStatus(fileInfo.file.name) == 'init'\">Ready to upload</span>\n                    <span v-else-if=\"getUploadStatus(fileInfo.file.name) == 'in_progress'\">Uploading...</span>\n                    <span v-else-if=\"getUploadStatus(fileInfo.file.name) == 'finished'\">Done!</span>\n                    <span v-else-if=\"getUploadStatus(fileInfo.file.name) == 'error'\">Error: {{ getUploadMessage(fileInfo.file.name) }}</span>\n                  </p>\n                  <p\n                    v-if=\"getUploadStatus(fileInfo.file.name) == 'in_progress'\"\n                    class=\"text-xs font-medium text-indigo-600\"\n                  >\n                    {{ getUploadProgress(fileInfo.file.name) }}%\n                  </p>\n                </div>\n\n                <!-- File Exists Warning -->\n                <div\n                  v-if=\"fileInfo?.fileExists && getUploadStatus(fileInfo.file.name) != 'finished'\"\n                  class=\"mt-2 flex items-center text-yellow-600\"\n                >\n                  <span class=\"text-xs\">\n                    {{\n                      __(\n                        'A file with this name already exists and will be overwritten.',\n                        'podlove-podcasting-plugin-for-wordpress'\n                      )\n                    }}\n                  </span>\n                </div>\n              </div>\n\n              <!-- Remove Button -->\n              <button\n                v-if=\"getUploadStatus(fileInfo.file.name) != 'in_progress'\"\n                class=\"flex-shrink-0 text-gray-400 hover:text-gray-600 focus:outline-none\"\n                @click=\"removeFile(fileInfo.newName)\"\n              >\n                <x-mark-icon class=\"w-4 h-4\" />\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <input\n          id=\"plus-file-upload\"\n          name=\"plus-file-upload\"\n          type=\"file\"\n          multiple\n          class=\"sr-only\"\n          ref=\"fileInput\"\n          @input=\"handleFileSelection\"\n        />\n      </label>\n\n      <podlove-button\n        v-if=\"state.selectedFiles && state.selectedFiles.length > 0 && hasFilesReadyToUpload\"\n        variant=\"primary\"\n        @click=\"plusUploadIntent\"\n        class=\"ml-1 mt-3\"\n        :disabled=\"hasFilesGeneratingFilenames\"\n      >\n        <upload-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" />\n        {{ hasFilesGeneratingFilenames ? __('Generating filenames...', 'podlove-podcasting-plugin-for-wordpress') : __('Upload Media Files', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n\n      <podlove-button\n        v-if=\"state.selectedFiles && state.selectedFiles.length > 0\"\n        variant=\"secondary\"\n        @click=\"selectAnotherFile\"\n        class=\"ml-1 mt-3\"\n      >\n        <plus-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" />\n        {{ __('Add more Files', 'podlove-podcasting-plugin-for-wordpress') }}\n      </podlove-button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport { plusUploadIntent, FileInfo } from '@store/mediafiles.store'\nimport PodloveButton from '@components/button/Button.vue'\nimport { CloudArrowUpIcon as UploadIcon, PlusIcon, DocumentTextIcon, XMarkIcon } from '@heroicons/vue/24/outline'\nimport { State, selectors } from '@store'\nimport * as mediafiles from '@store/mediafiles.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    UploadIcon,\n    DocumentTextIcon,\n    XMarkIcon,\n    PlusIcon,\n  },\n  data() {\n    return {}\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        progress: (state: State) => (key: string) => selectors.progress.progress(state, key),\n        status: (state: State) => (key: string) => selectors.progress.status(state, key),\n        message: (state: State) => (key: string) => selectors.progress.message(state, key),\n        episodeSlug: (state: State) => selectors.episode.slug(state),\n        selectedFiles: (state: State) => selectors.mediafiles.selectedFiles(state),\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    plusUploadIntent() {\n      if (this.state.selectedFiles && this.state.selectedFiles.length > 0) {\n        this.state.selectedFiles.forEach((fileInfo: FileInfo) => {\n          this.dispatch(plusUploadIntent(fileInfo.file))\n        })\n      }\n    },\n    handleFileSelection(event: Event): void {\n      const fileList = (event.target as HTMLInputElement).files\n      if (!fileList || fileList.length === 0) {\n        return\n      }\n\n      const filesArray = Array.from(fileList)\n      const episodeSlug = this.state.episodeSlug\n\n      const existingFiles = this.state.selectedFiles || []\n      const existingFileObjects = existingFiles.map((fileInfo: FileInfo) => fileInfo.file)\n      const allFiles = [...existingFileObjects, ...filesArray]\n\n      this.dispatch(mediafiles.fileSelected(allFiles, episodeSlug))\n    },\n    resetFiles() {\n      this.dispatch({ type: mediafiles.SET_FILE_INFO, payload: [] })\n    },\n    removeFile(fileName: string) {\n      this.dispatch(mediafiles.removeSelectedFile(fileName))\n    },\n    triggerFileInput() {\n      const fileInput = this.$refs.fileInput as HTMLInputElement\n      if (fileInput) {\n        fileInput.click()\n      }\n    },\n    selectAnotherFile() {\n      this.triggerFileInput()\n    },\n    getUploadProgress(fileName: string): number | null {\n      const key = `plus-upload-${fileName}`\n      return this.state.progress(key) || null\n    },\n    getUploadStatus(fileName: string): string | null {\n      const key = `plus-upload-${fileName}`\n      return this.state.status(key) || null\n    },\n    getUploadMessage(fileName: string): string | null {\n      const key = `plus-upload-${fileName}`\n      return this.state.message(key) || null\n    },\n    getFilenameGenerationStatus(originalName: string): string | null {\n      const key = `filename-generation-${originalName}`\n      return this.state.status(key) || null\n    },\n  },\n\n  computed: {\n    selectedFiles(): FileInfo[] {\n      return this.state.selectedFiles || []\n    },\n    hasFilesReadyToUpload(): boolean {\n      return this.selectedFiles.some(fileInfo => {\n        const status = this.getUploadStatus(fileInfo.file.name)\n        return status === 'init' || status === null\n      })\n    },\n    allFilesUploaded(): boolean {\n      return this.selectedFiles.length > 0 && this.selectedFiles.every(fileInfo => {\n        const status = this.getUploadStatus(fileInfo.file.name)\n        return status === 'finished'\n      })\n    },\n    hasFilesGeneratingFilenames(): boolean {\n      return this.selectedFiles.some(fileInfo => {\n        const status = this.getFilenameGenerationStatus(fileInfo.originalName)\n        return status === 'in_progress'\n      })\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/mediafiles/index.ts",
    "content": "import MediaFiles from './MediaFiles.vue'\n\nexport default MediaFiles\n"
  },
  {
    "path": "client/src/modules/plus_features/Feature.vue",
    "content": "<template>\n  <div\n    class=\"overflow-hidden rounded-lg bg-white border border-gray-200 transition-shadow duration-200 hover:shadow\"\n  >\n    <div class=\"px-4 py-5 sm:p-6\">\n      <div class=\"mb-3 flex items-center justify-between\">\n        <h3 class=\"text-base font-medium text-gray-800\">{{ title }}</h3>\n        <div class=\"flex items-center gap-3\">\n          <div class=\"flex items-center gap-2\">\n            <slot name=\"settings-action\"></slot>\n            <span\n              v-if=\"modelValue\"\n              class=\"rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-600\"\n              >Active</span\n            >\n            <span v-else class=\"rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-500\"\n              >Disabled</span\n            >\n          </div>\n          <Switch\n            :modelValue=\"modelValue\"\n            @update:modelValue=\"$emit('update:modelValue', $event)\"\n            :class=\"[\n              modelValue ? 'bg-green-600' : 'bg-gray-200',\n              'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2',\n            ]\"\n          >\n            <span\n              :class=\"[\n                modelValue ? 'translate-x-5' : 'translate-x-0',\n                'pointer-events-none relative inline-block size-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',\n              ]\"\n            >\n              <span\n                :class=\"[\n                  modelValue\n                    ? 'opacity-0 duration-100 ease-out'\n                    : 'opacity-100 duration-200 ease-in',\n                  'absolute inset-0 flex size-full items-center justify-center transition-opacity',\n                ]\"\n                aria-hidden=\"true\"\n              >\n                <svg class=\"size-3 text-gray-400\" fill=\"none\" viewBox=\"0 0 12 12\">\n                  <path\n                    d=\"M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                  />\n                </svg>\n              </span>\n              <span\n                :class=\"[\n                  modelValue\n                    ? 'opacity-100 duration-200 ease-in'\n                    : 'opacity-0 duration-100 ease-out',\n                  'absolute inset-0 flex size-full items-center justify-center transition-opacity',\n                ]\"\n                aria-hidden=\"true\"\n              >\n                <svg class=\"size-3 text-green-600\" fill=\"currentColor\" viewBox=\"0 0 12 12\">\n                  <path\n                    d=\"M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z\"\n                  />\n                </svg>\n              </span>\n            </span>\n          </Switch>\n        </div>\n      </div>\n      <slot></slot>\n    </div>\n    <div class=\"bg-gray-50 px-4 py-4 sm:px-6\" v-if=\"$slots.footer\">\n      <slot name=\"footer\"></slot>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { Switch } from '@headlessui/vue'\n\nconst props = defineProps({\n  title: {\n    type: String,\n    required: true,\n  },\n  modelValue: {\n    type: Boolean,\n    required: true,\n  },\n})\n\ndefineEmits(['update:modelValue'])\n</script>\n"
  },
  {
    "path": "client/src/modules/plus_features/PlusFeatures.vue",
    "content": "<template>\n  <div class=\"mb-6 rounded-lg bg-white p-6 shadow-sm\">\n    <div class=\"mb-6\">\n      <h2 class=\"mb-2 text-xl font-medium text-gray-700\">Manage Features</h2>\n      <p class=\"text-sm text-gray-600\">\n        Enable or disable PLUS features. Changes will take effect immediately.\n      </p>\n    </div>\n\n    <div class=\"space-y-3\">\n      <Feature\n        title=\"Podcast File Hosting\"\n        :modelValue=\"features.fileStorage\"\n        @update:modelValue=\"handleFeatureToggle('fileStorage')\"\n      >\n        <template #settings-action v-if=\"features.fileStorage\">\n          <button\n            @click=\"toggleMigrationTool\"\n            class=\"p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center gap-1\"\n            title=\"Show Migration Tool\"\n          >\n            <Cog6ToothIcon class=\"size-5\" /> <span>Show Migration Tool</span>\n          </button>\n        </template>\n\n        <p class=\"text-sm text-gray-600 mb-2\">\n          Keep your podcast files in fast, reliable cloud hosting built for podcast delivery. As\n          your show grows, you can avoid the storage and performance limits of serving files\n          directly from WordPress.\n        </p>\n\n        <p class=\"text-sm text-gray-600 mb-2\">\n          Enable Podcast File Hosting here to automatically upload your media files and make them\n          available from Publisher PLUS.\n        </p>\n\n        <p class=\"text-sm text-gray-600\">\n          You can disable it again at any time. Your files will then be served from the WordPress\n          or FTP storage location configured in the plugin.\n        </p>\n\n        <template #footer v-if=\"features.fileStorage && (needsMigration || showMigrationTool)\">\n          <PlusFileMigration />\n        </template>\n      </Feature>\n\n      <Feature\n        title=\"Reliable Feed Delivery\"\n        :modelValue=\"features.feedProxy\"\n        @update:modelValue=\"handleFeatureToggle('feedProxy')\"\n      >\n        <p class=\"text-sm text-gray-600\">\n          Keep your podcast feed fast and available even during traffic spikes. When enabled,\n          Publisher PLUS automatically routes feed requests through our optimized delivery\n          infrastructure, and you can turn it off again at any time without losing subscribers.\n        </p>\n      </Feature>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { Cog6ToothIcon } from '@heroicons/vue/24/outline'\nimport Feature from './Feature.vue'\nimport PlusFileMigration from '../plus_file_migration/PlusFileMigration.vue'\nimport * as plusFileMigration from '@store/plusFileMigration.store'\nimport { injectStore, mapState } from 'redux-vuex'\nimport * as plus from '@store/plus.store'\nimport { selectors } from '@store'\nimport type { PlusFeatures } from '@store/plus.store'\n\nexport default defineComponent({\n  components: {\n    Feature,\n    PlusFileMigration,\n    Cog6ToothIcon,\n  },\n\n  setup() {\n    return {\n      state: mapState({\n        features: selectors.plus.features,\n        files: selectors.plusFileMigration.episodesWithFiles,\n        isMigrationComplete: selectors.plusFileMigration.isMigrationComplete,\n        showMigrationToolManually: selectors.plusFileMigration.showMigrationToolManually,\n      }),\n      dispatch: injectStore().dispatch,\n    }\n  },\n  created() {\n    this.dispatch(plus.init())\n    this.dispatch(plusFileMigration.init())\n  },\n\n  methods: {\n    handleFeatureToggle(featureKey: keyof PlusFeatures) {\n      this.dispatch(plus.setFeature({ feature: featureKey, value: !this.features[featureKey] }))\n    },\n    toggleMigrationTool() {\n      this.dispatch(plusFileMigration.toggleMigrationToolManually())\n    },\n  },\n\n  computed: {\n    features(): PlusFeatures {\n      return this.state.features\n    },\n    needsMigration(): boolean {\n      return !this.state.isMigrationComplete && this.state.files && this.state.files.length > 0\n    },\n    showMigrationTool(): boolean {\n      return this.state.showMigrationToolManually\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/plus_features/index.ts",
    "content": "import PlusFeatures from './PlusFeatures.vue'\n\nexport default PlusFeatures\n"
  },
  {
    "path": "client/src/modules/plus_file_migration/PlusFileMigration.vue",
    "content": "<template>\n  <div class=\"m-3 rounded-lg bg-white\">\n    <section class=\"bg-white w-full\" v-if=\"uiState === 'init'\">\n      <div class=\"text-center\">loading...</div>\n    </section>\n\n    <section class=\"bg-white w-full\" v-if=\"uiState === 'finished'\">\n      <div class=\"text-center py-10 px-5\">\n        <div\n          class=\"w-20 h-20 mx-auto mb-5 bg-green-50 rounded-full flex items-center justify-center\"\n        >\n          <CheckBadgeIcon class=\"w-10 h-10 stroke-green-500\" />\n        </div>\n        <h3 class=\"text-lg font-medium text-gray-800 mb-2.5\">Upload Complete!</h3>\n        <p class=\"text-gray-600 text-sm mb-6 max-w-md mx-auto\">\n          <span v-if=\"failedFiles === 0\">All your files have been successfully uploaded.</span>\n          <span v-else>Upload process completed with {{ failedFiles }} failed upload{{ failedFiles > 1 ? 's' : '' }}.</span>\n        </p>\n\n        <div class=\"bg-gray-50 rounded-lg p-5 mx-auto m-5 text-left\">\n          <div class=\"text-base font-medium text-gray-800 mb-4\">Upload Summary</div>\n          <div class=\"flex justify-between mb-2.5 text-sm text-gray-600\">\n            <span>Total Episodes:</span>\n            <span>{{ totalEpisodes }}</span>\n          </div>\n          <div class=\"flex justify-between mb-2.5 text-sm text-gray-600\">\n            <span>Total Files:</span>\n            <span>{{ totalFiles }}</span>\n          </div>\n          <div v-if=\"failedFiles > 0\" class=\"flex justify-between mb-4 text-sm text-red-600\">\n            <span>Failed Uploads:</span>\n            <span>{{ failedFiles }}</span>\n          </div>\n\n          <div v-if=\"failedFiles > 0\" class=\"border-t border-gray-200 pt-4\">\n            <div class=\"text-sm font-medium text-gray-800 mb-3\">Failed Uploads:</div>\n            <div class=\"space-y-2\">\n              <div v-for=\"failedFile in failedFilesList\" :key=\"`${failedFile.episodeName}-${failedFile.fileName}`\"\n                   class=\"bg-red-50 border border-red-200 rounded p-3\">\n                <div class=\"text-sm text-red-800\">\n                  <div class=\"font-medium\">{{ failedFile.episodeName }}</div>\n                  <div class=\"text-red-600 mt-1\">{{ failedFile.fileName }}</div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <p class=\"text-gray-600 text-left text-sm px-2 mx-auto\">\n          <span v-if=\"failedFiles === 0\">\n            Starting immediately, your files will be served from PLUS Cloud Storage to all listeners.\n            When you create and manage new episodes, files will be directly uploaded to PLUS Cloud\n            Storage.<br /><br />Happy podcasting!\n          </span>\n          <span v-else>\n            Successfully uploaded files will be served from PLUS Cloud Storage to all listeners.\n            You may want to retry uploading the failed files or check the file URLs and try again.\n            When you create and manage new episodes, files will be directly uploaded to PLUS Cloud\n            Storage.\n          </span>\n        </p>\n      </div>\n    </section>\n\n    <section class=\"bg-white w-full\" v-if=\"uiState === 'ready'\">\n      <div class=\"text-center py-10 px-5\">\n        <div\n          class=\"w-20 h-20 mx-auto mb-5 bg-gray-100 rounded-full flex items-center justify-center\"\n        >\n          <UploadIcon class=\"w-10 h-10 stroke-gray-600\" />\n        </div>\n        <h3 class=\"text-lg font-medium text-gray-800 mb-2.5\">\n          Upload Your Existing Media Files to PLUS Cloud Storage\n        </h3>\n        <p class=\"text-left text-gray-600 text-sm mb-2.5 max-w-md mx-auto\">\n          This is a one-time operation to move your existing files to PLUS Cloud Storage. It will\n          only need to be done once.\n        </p>\n        <p class=\"text-left text-gray-600 text-sm mb-6 max-w-md mx-auto\">\n          You have {{ totalFiles }} files to upload. Once they are uploaded, you can delete the\n          files from your local storage or keep them as a backup.\n        </p>\n        <podlove-button variant=\"primary\" @click=\"startMigration\">{{\n          __('Start Uploads', 'podlove-podcasting-plugin-for-wordpress')\n        }}</podlove-button>\n      </div>\n    </section>\n\n    <section class=\"bg-white w-full\" v-if=\"uiState === 'in_progress'\">\n      <div class=\"py-10 px-5\">\n        <div class=\"flex justify-between mb-2 text-sm text-gray-600\">\n          <span>Progress Uploading Media Files to PLUS Cloud Storage</span>\n          <span>{{ progress }}%</span>\n        </div>\n        <div class=\"h-2.5 bg-gray-100 rounded-lg overflow-hidden\">\n          <div\n            class=\"h-full bg-green-500 rounded-lg transition-all duration-300\"\n            :style=\"{ width: progress + '%' }\"\n          ></div>\n        </div>\n\n        <section class=\"bg-gray-50 my-5 p-5 rounded-lg\">\n          <div class=\"flex items-center mb-2\">\n            <h3 class=\"text-base font-medium text-gray-800\">Currently Uploading</h3>\n            <div class=\"ml-2 animate-spin\">\n              <div class=\"w-4 h-4 border-2 border-gray-300 border-t-gray-700 rounded-full\"></div>\n            </div>\n          </div>\n          <p class=\"text-gray-600 text-sm mb-1\">\n            <strong>Episode:</strong> {{ currentEpisodeName }}\n          </p>\n          <p class=\"text-gray-600 text-sm\"><strong>File:</strong> {{ currentFileName }}</p>\n        </section>\n\n        <div class=\"border-l-4 border-yellow-400 bg-yellow-50 p-4\">\n          <div class=\"flex\">\n            <div class=\"shrink-0\">\n              <ExclamationTriangleIcon class=\"size-5 text-yellow-400\" aria-hidden=\"true\" />\n            </div>\n            <div class=\"ml-3\">\n              <p class=\"text-sm text-yellow-700\">\n                Keep this window open while the upload is in progress.\n              </p>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { injectStore, mapState } from 'redux-vuex'\nimport Module from '@components/module/Module.vue'\nimport * as plusFileMigration from '@store/plusFileMigration.store'\nimport { selectors } from '@store'\n\nimport PodloveButton from '@components/button/Button.vue'\n\nimport { CloudArrowUpIcon as UploadIcon, CheckBadgeIcon } from '@heroicons/vue/24/outline'\n\nimport { ExclamationTriangleIcon } from '@heroicons/vue/24/solid'\n\nexport default defineComponent({\n  name: 'PlusFileMigration',\n  components: {\n    Module,\n    PodloveButton,\n    UploadIcon,\n    CheckBadgeIcon,\n    ExclamationTriangleIcon,\n  },\n  setup() {\n    return {\n      state: mapState({\n        totalState: selectors.plusFileMigration.totalState,\n        progress: selectors.plusFileMigration.progress,\n        files: selectors.plusFileMigration.episodesWithFiles,\n        currentEpisodeName: selectors.plusFileMigration.currentEpisodeName,\n        currentFileName: selectors.plusFileMigration.currentFileName,\n        isMigrationComplete: selectors.plusFileMigration.isMigrationComplete,\n      }),\n      dispatch: injectStore().dispatch,\n    }\n  },\n  methods: {\n    startMigration() {\n      this.dispatch(plusFileMigration.startMigration())\n    },\n  },\n  computed: {\n    progress(): number {\n      return this.state.progress\n    },\n    totalFiles(): number {\n      return this.state.files.reduce((acc: number, file: any) => acc + file.files.length, 0)\n    },\n    totalEpisodes(): number {\n      return this.state.files.length\n    },\n    failedFiles(): number {\n      return this.state.files.reduce((acc: number, episode: any) => {\n        return acc + episode.files.filter((file: any) => file.state === 'error').length\n      }, 0)\n    },\n    failedFilesList(): Array<{episodeName: string, fileName: string}> {\n      const failedFiles: Array<{episodeName: string, fileName: string}> = []\n      this.state.files.forEach((episode: any) => {\n        episode.files.forEach((file: any) => {\n          if (file.state === 'error') {\n            failedFiles.push({\n              episodeName: episode.episodeName,\n              fileName: file.name\n            })\n          }\n        })\n      })\n      return failedFiles\n    },\n    currentEpisodeName(): string {\n      return this.state.currentEpisodeName\n    },\n    currentFileName(): string {\n      return this.state.currentFileName\n    },\n    uiState(): string {\n      return this.state.totalState\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/plus_file_migration/index.ts",
    "content": "import PlusFileMigration from './PlusFileMigration.vue'\n\nexport default PlusFileMigration\n"
  },
  {
    "path": "client/src/modules/plus_token/PlusToken.vue",
    "content": "<template>\n  <div class=\"mb-6 rounded-lg bg-white p-6 shadow-sm\">\n    <div class=\"mb-6\">\n      <h2 class=\"mb-2 text-xl font-medium text-gray-700\">{{ __('Authentication', 'podlove-podcasting-plugin-for-wordpress') }}</h2>\n      <p class=\"text-sm text-gray-600\">\n        {{ __('Publisher PLUS provides additional features and services for your podcast. Enter your API token below to activate these features.', 'podlove-podcasting-plugin-for-wordpress') }}\n      </p>\n    </div>\n\n    <!-- Loading state -->\n    <div v-if=\"state.isLoading\" class=\"space-y-3\">\n      <div class=\"animate-pulse\">\n        <div class=\"h-4 bg-gray-200 rounded w-3/4 mb-4\"></div>\n        <div class=\"h-10 bg-gray-200 rounded mb-4\"></div>\n        <div class=\"h-4 bg-gray-200 rounded w-1/2\"></div>\n      </div>\n    </div>\n\n    <!-- Content when loaded -->\n    <div v-else class=\"space-y-3\">\n      <TokenInput\n        :modelValue=\"apiToken\"\n        :isTokenValid=\"isTokenValid\"\n        :isSaving=\"state.isSaving\"\n        @update:modelValue=\"handleTokenUpdate\"\n        @save=\"handleSaveToken\"\n      >\n         <template #status>\n          <div v-if=\"apiToken && isTokenValid\" class=\"flex items-center gap-2 text-sm text-gray-600\">\n            <CheckCircleIcon class=\"w-4 h-4 text-green-600\" />\n            <span>{{ __('You are logged in as', 'podlove-podcasting-plugin-for-wordpress') }} <strong>{{ userEmail }}</strong></span>\n          </div>\n          <div v-else-if=\"apiToken && !isTokenValid\" class=\"flex items-center gap-2 text-sm text-red-600\">\n            <XCircleIcon class=\"w-4 h-4\" />\n            <span>{{ __('Invalid API token', 'podlove-podcasting-plugin-for-wordpress') }}</span>\n          </div>\n          <div v-else class=\"flex items-center gap-2 text-sm text-gray-600\">\n            <InformationCircleIcon class=\"w-4 h-4\" />\n            <span>{{ __('No API token configured', 'podlove-podcasting-plugin-for-wordpress') }}</span>\n          </div>\n        </template>\n      </TokenInput>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport TokenInput from './TokenInput.vue'\nimport { CheckCircleIcon, XCircleIcon, InformationCircleIcon } from '@heroicons/vue/24/solid'\nimport { injectStore, mapState } from 'redux-vuex'\nimport * as plus from '@store/plus.store'\nimport { selectors } from '@store'\n\nexport default defineComponent({\n  components: {\n    TokenInput,\n    CheckCircleIcon,\n    XCircleIcon,\n    InformationCircleIcon,\n  },\n\n  setup() {\n    const store = injectStore()\n    return {\n      state: mapState({\n        token: (state: any) => selectors.plus.token(state),\n        user: (state: any) => selectors.plus.user(state),\n        isLoading: (state: any) => selectors.plus.isLoading(state),\n        isSaving: (state: any) => selectors.plus.isSaving(state),\n      }),\n      dispatch: store.dispatch,\n    }\n  },\n\n  created() {\n    this.dispatch(plus.init())\n  },\n\n  computed: {\n    apiToken(): string {\n      return this.state.token\n    },\n    isTokenValid(): boolean {\n      return this.state.user !== null\n    },\n    userEmail(): string {\n      return this.state.user?.email || ''\n    },\n    showContent(): boolean {\n      return !this.state.isLoading\n    },\n  },\n\n  methods: {\n    handleTokenUpdate(token: string) {\n      this.dispatch(plus.setToken(token))\n    },\n    handleSaveToken() {\n      this.dispatch(plus.saveToken(this.apiToken))\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/plus_token/TokenInput.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <div>\n      <label for=\"api_token\" class=\"block text-sm font-medium text-gray-700 mb-2\">\n        {{ __('API Token', 'podlove-podcasting-plugin-for-wordpress') }}\n      </label>\n      <div class=\"relative\">\n        <input\n          :type=\"showToken ? 'text' : 'password'\"\n          id=\"api_token\"\n          :value=\"modelValue\"\n          @input=\"$emit('update:modelValue', ($event.target as HTMLInputElement).value)\"\n          class=\"w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500\"\n          :placeholder=\"__('Enter your API token', 'podlove-podcasting-plugin-for-wordpress')\"\n        />\n        <button\n          type=\"button\"\n          @click=\"showToken = !showToken\"\n          class=\"absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none\"\n        >\n          <EyeIcon v-if=\"showToken\" class=\"h-5 w-5\" />\n          <EyeSlashIcon v-else class=\"h-5 w-5\" />\n        </button>\n      </div>\n    </div>\n\n    <div class=\"text-sm text-gray-600\">\n      <slot></slot>\n    </div>\n\n    <div class=\"flex items-center justify-between\">\n      <div class=\"flex-1\">\n        <slot name=\"status\"></slot>\n      </div>\n\n      <div class=\"flex gap-2\">\n        <a\n          href=\"https://plus.podlove.org/tokens\"\n          target=\"_blank\"\n          class=\"inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\"\n        >\n          {{ __('Get Token', 'podlove-podcasting-plugin-for-wordpress') }}\n        </a>\n        <button\n          type=\"button\"\n          :disabled=\"isSaving\"\n          @click=\"$emit('save')\"\n          class=\"inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n          <svg v-if=\"isSaving\" class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-white\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n            <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n            <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n          </svg>\n          {{ isSaving ? __('Saving...', 'podlove-podcasting-plugin-for-wordpress') : __('Save Token', 'podlove-podcasting-plugin-for-wordpress') }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\n\nimport { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '',\n  },\n  isTokenValid: {\n    type: Boolean,\n    default: false,\n  },\n  isSaving: {\n    type: Boolean,\n    default: false,\n  },\n})\n\ndefineEmits(['update:modelValue', 'save'])\n\nconst showToken = ref(false)\n</script>\n"
  },
  {
    "path": "client/src/modules/plus_token/index.ts",
    "content": "import PlusToken from './PlusToken.vue'\n\nexport default PlusToken\n"
  },
  {
    "path": "client/src/modules/related/RelatedEpisodes.vue",
    "content": "<template>\n  <module name=\"relatedEpisodes\" :title=\"__('Related episodes', 'podlove-podcasting-plugin-for-wordpress')\">\n    <div class=\"p-3\">\n      <div>\n        <PodloveListbox\n          placeholder=\"Select episode\"\n          :options=\"fullEpisodeList\"\n          :selectValues=\"state.selectEpisodes\"\n          @update = \"updateRelEpisodes($event)\"\n          multiple\n        />\n      </div>\n      <p class=\"mt-2 text-sm text-gray-500\">{{ __('Select related episodes to this episode.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n    </div>\n    <div class=\"pl-3 pb-3\">\n      <Tag v-for=\"name in selectEpisodeNames\"\n        :value=\"name.title\"\n        :id=\"Number(name.id)\"\n        @removeTag = \"removeTag($event)\"\n        >\n      </Tag>\n    </div>\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nimport { selectors } from '@store'\n\nimport Module from '@components/module/Module.vue'\nimport PodloveListbox, { OptionObject } from '@components/combobox/Combobox.vue'\nimport Tag from '@components/tag/Tag.vue';\nimport * as related from '@store/relatedEpisodes.store'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport { PodloveEpisodeList } from '../../types/relatedEpisodes.types'\n\ntype RelatedOption = OptionObject\n\nexport default defineComponent({\n  components: {\n    Module,\n    PodloveListbox,\n    Tag,\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        episodeList: selectors.relatedEpisodes.episodeList,\n        selectEpisodes: selectors.relatedEpisodes.selectEpisode,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n  created() {\n    this.dispatch(related.init())\n  },\n  computed: {\n    episodeOptions(): RelatedOption[] {\n      return this.state.episodeList.map((episode: PodloveEpisodeList) => ({\n        id: episode.episode_id,\n        title: episode.episode_title,\n      }))\n    },\n    fullEpisodeList() : Array<OptionObject> {\n      if (this.episodeOptions.length == 0)\n        return this.episodeOptions\n      if (this.state.selectEpisodes.length == 0) {\n        const selectAllEpisodes = { id: 0, title: \"Select all episodes\"}\n        return [selectAllEpisodes, ...this.episodeOptions]\n      }\n      else {\n        const selectAllEpisodes = { id: -1, title: \"Deselect all episodes\"}\n        return [selectAllEpisodes, ...this.episodeOptions]\n      }\n    },\n    selectEpisodeNames() : Array<OptionObject> | null {\n      return this.episodeOptions.filter((episode: RelatedOption) => {\n        return this.state.selectEpisodes.includes( episode.id )\n      })\n    }\n  },\n  methods: {\n    updateRelEpisodes(newSelectedItems: Array<Number>) {\n      if (newSelectedItems.includes(0)) {\n        // Select all\n        this.state.selectEpisodes = this.episodeOptions.map((item: RelatedOption) => item.id)\n      }\n      else if (newSelectedItems.includes(-1)) {\n        // deselect all\n        this.state.selectEpisodes = []\n      }\n      else {\n        this.state.selectEpisodes = newSelectedItems\n      }\n      this.dispatch(related.setSelectedEpisodes(this.state.selectEpisodes))\n    },\n    removeTag(removeId: Number) {\n      const idx = this.state.selectEpisodes.findIndex( (elem: Number) => {\n        return elem == removeId;\n      })\n      if (idx === -1) {\n        return\n      }\n\n      this.state.selectEpisodes.splice(idx, 1);\n      this.dispatch(related.setSelectedEpisodes([...this.state.selectEpisodes]))\n    }\n  }\n})\n\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/related/index.ts",
    "content": "import RelatedEpisodes from './RelatedEpisodes.vue';\n\nexport default RelatedEpisodes;"
  },
  {
    "path": "client/src/modules/shows/ShowSelect.vue",
    "content": "<template>\n  <div>\n    <div class=\"flex items-center\">\n      <input\n        id=\"podlove-show-default\"\n        name=\"podlove-episode-show\"\n        type=\"radio\"\n        class=\"h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600\"\n        :value=\"null\"\n        @input=\"setShow(null)\"\n        :checked=\"current === ''\"\n      />\n      <label for=\"podlove-show-default\" class=\"ml-2 block text-sm leading-6 text-gray-900\">{{\n        __('Podcast (no show assignment)', 'podlove-podcasting-plugin-for-wordpress')\n      }}</label>\n    </div>\n    <div v-for=\"show in shows\" class=\"flex items-center\">\n      <input\n        :id=\"'podlove-show-' + show.slug\"\n        name=\"podlove-episode-show\"\n        type=\"radio\"\n        class=\"h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600\"\n        :value=\"show.id\"\n        @input=\"setShow(show.slug)\"\n        :checked=\"show.slug === current\"\n      />\n      <label\n        :for=\"'podlove-show-' + show.slug\"\n        class=\"ml-2 block text-sm leading-6 text-gray-900\"\n        >{{ show.title }}</label\n      >\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport { injectStore, mapState } from 'redux-vuex'\n\nimport * as shows from '@store/shows.store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport { selectors } from '@store'\nimport { PodloveShow } from '../../types/shows.types'\n\nexport default defineComponent({\n  components: {},\n\n  setup() {\n    return {\n      state: mapState({\n        shows: selectors.shows.list,\n        current: selectors.episode.currentShow,\n      }),\n      dispatch: injectStore().dispatch,\n    }\n  },\n\n  computed: {\n    shows(): PodloveShow[] {\n      return this.state.shows\n    },\n    current(): string | null {\n      return this.state.current || ''\n    },\n  },\n\n  methods: {\n    setShow(show: string | null): void {\n      this.dispatch(updateEpisode({ prop: 'show', value: show ?? '' }))\n    },\n  },\n\n  created() {\n    this.dispatch(shows.init())\n  },\n})\n</script>\n\n<style scoped>\ndiv > input[type='radio']:checked::before {\n  background-color: white;\n}\n</style>\n"
  },
  {
    "path": "client/src/modules/shows/index.ts",
    "content": "import ShowSelect from './ShowSelect.vue'\n\nexport default ShowSelect\n"
  },
  {
    "path": "client/src/modules/soundbite/Soundbite.vue",
    "content": "<template>\n    <module name=\"soundbite\" :title=\"__('Soundbite', 'podlove-podcasting-plugin-for-wordpress')\">\n        <template v-slot:actions>\n            <soundbite-clear></soundbite-clear>\n        </template>\n        <soundbite-form></soundbite-form>\n    </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\n\nimport Module from '@components/module/Module.vue'\nimport * as episode from '@store/episode.store'\nimport { injectAppDispatch } from '@store/vue'\n\nimport SoundbiteClear from './components/Clear.vue'\nimport SoundbiteForm from './components/Form.vue'\n\nexport default defineComponent({\n    components: {\n        Module,\n        SoundbiteForm,\n        SoundbiteClear,\n    },\n\n    setup(): { dispatch: Function } {\n        return {\n            dispatch: injectAppDispatch(),\n        }\n    },\n\n    created() {\n        this.dispatch(episode.init())\n    }\n})\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/soundbite/components/Clear.vue",
    "content": "<template>\n    <podlove-button variant=\"secondary\" size=\"small\" @click=\"clearSoundbite()\">{{ __('Clear', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\n\nimport { update as updateEpisode } from '@store/episode.store'\nimport { injectAppDispatch } from '@store/vue'\n\nimport PodloveButton from '@components/button/Button.vue'\n\nexport default defineComponent({\n  components: { PodloveButton },\n\n  setup() {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    clearSoundbite() {\n      this.dispatch(\n        updateEpisode({prop: 'soundbite_start', value: null })\n      )\n      this.dispatch(\n        updateEpisode({prop: 'soundbite_duration', value: null }),\n      )\n      this.dispatch(\n        updateEpisode({prop: 'soundbite_title', value: null })\n      )\n    }\n  },\n\n})\n\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/soundbite/components/Form.vue",
    "content": "<template>\n  <div class=\"grid md:grid-cols-4 md:grid-rows-1 sm:grid-rows-4 p-3\">\n    <div class=\"mb-5 ml-5\">\n      <label for=\"soundbite-start\" class=\"block text-sm font-medium text-gray-700\">{{ __('Start', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n      <div class=\"mt-1\">\n        <input\n          name=\"soundbite-start\"\n          type=\"text\"\n          class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n          placeholder=\"HH:MM:SS\"\n          :value=\"state.soundbite_start\"\n          @change=\"updateSoundbiteStart($event)\"\n        />\n      </div>\n    </div>\n    <div class=\"mb-5 ml-5\">\n      <label for=\"soundbite-end\" class=\"block text-sm font-medium text-gray-700\">{{ __('End', 'podlove-podcasting-plugin-for-wordpress') }}</label\n      >\n      <div class=\"mt-1\">\n        <input\n          name=\"soundbite-end\"\n          type=\"text\"\n          class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n          placeholder=\"HH:MM:SS\"\n          :value=soundbite_end\n          @change=\"updateSoundbiteEnd($event)\"\n        />\n      </div>\n    </div>\n    <div class=\"mb-5 ml-5\">\n      <label for=\"soundbite-duration\" class=\"block text-sm font-medium text-gray-700\">{{ __('Duration', 'podlove-podcasting-plugin-for-wordpress') }}</label>\n      <div class=\"mt-1\">\n        <input\n          name=\"soundbite-duration\"\n          type=\"text\"\n          class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n          placeholder=\"HH:MM:SS\"\n          :value=\"state.soundbite_duration\"\n          @change=\"updateSoundbiteDuration($event)\"\n        />\n      </div>\n    </div>\n    <div class=\"mb-5 ml-5 md:mr-5\">\n      <label for=\"soundbite-title\" class=\"block text-sm font-medium text-gray-700\">{{ __('Soundbite title', 'podlove-podcasting-plugin-for-wordpress') }}\n        <span class=\"text-xs\">{{ __('(optional)', 'podlove-podcasting-plugin-for-wordpress') }}</span></label\n      >\n      <div class=\"mt-1\">\n        <input\n          name=\"soundbite-title\"\n          type=\"text\"\n          class=\"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md\"\n          :value=\"state.soundbite_title\"\n          @change=\"updateSoundbiteTitle($event)\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { selectors } from '@store'\nimport { update as updateEpisode } from '@store/episode.store'\nimport Timestamp from '@lib/timestamp'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n    setup() {\n        return {\n            state: mapAppState({\n                soundbite_start: selectors.episode.soundbite_start,\n                soundbite_duration: selectors.episode.soundbite_duration,\n                soundbite_title: selectors.episode.soundbite_title,\n            }),\n            dispatch: injectAppDispatch(),\n        }\n    },\n\n    computed: {\n      soundbite_end(): string {\n        if (this.state.soundbite_start != null && this.state.soundbite_duration != null) {\n          let start = Timestamp.fromString(this.state.soundbite_start).totalMs\n          let duration = Timestamp.fromString(this.state.soundbite_duration).totalMs\n          let end = start + duration\n          return new Timestamp(end).pretty\n        }\n        return \"\"\n      }\n    },\n\n    methods: {\n      updateSoundbiteStart(event: Event) {\n        const raw = (event.target as HTMLInputElement).value\n        let value = Timestamp.fromString(raw).totalMs\n\n        this.dispatch(\n          updateEpisode({prop: 'soundbite_start', value: new Timestamp(value).pretty })\n        )\n      },\n      updateSoundbiteEnd(event: Event) {\n        const raw = (event.target as HTMLInputElement).value\n        let end = Timestamp.fromString(raw).totalMs\n        let start = Timestamp.fromString(this.state.soundbite_start || '').totalMs\n        if (start < end) {\n          let duration = end - start;\n          this.dispatch(\n            updateEpisode({prop: 'soundbite_duration', value: new Timestamp(duration).pretty })\n          )\n        }\n      },\n      updateSoundbiteDuration(event: Event) {\n        const raw = (event.target as HTMLInputElement).value\n        let value = Timestamp.fromString(raw).totalMs\n\n        this.dispatch(\n          updateEpisode({prop: 'soundbite_duration', value: new Timestamp(value).pretty })\n        )\n      },\n      updateSoundbiteTitle(event: Event) {\n        this.dispatch(\n          updateEpisode({prop: 'soundbite_title', value: (event.target as HTMLInputElement).value })\n        )\n      },\n    },\n})\n\n</script>\n\n<style lang=\"postcss\">\n</style>\n"
  },
  {
    "path": "client/src/modules/soundbite/index.ts",
    "content": "import Soundbite from './Soundbite.vue';\n\nexport default Soundbite;\n"
  },
  {
    "path": "client/src/modules/transcripts/Transcripts.vue",
    "content": "<template>\n  <module name=\"transcript\" :title=\"__('Transcripts', 'podlove-podcasting-plugin-for-wordpress')\">\n    <template v-slot:actions>\n      <transcripts-voices class=\"mr-1\" />\n      <transcripts-export class=\"mr-1\" />\n      <transcripts-delete />\n    </template>\n    <transcripts-list />\n  </module>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue'\nimport { TabsContainer, Tab } from '@components/tabs'\n\nimport Module from '@components/module/Module.vue'\nimport * as transcripts from '@store/transcripts.store'\nimport * as contributors from '@store/contributors.store'\nimport { injectAppDispatch } from '@store/vue'\n\nimport TranscriptsList from './components/List.vue'\nimport TranscriptsVoices from './components/Voices.vue'\nimport TranscriptsExport from './components/Export.vue'\nimport TranscriptsDelete from './components/Delete.vue'\n\nexport default defineComponent({\n  components: {\n    Module,\n    TabsContainer,\n    Tab,\n    TranscriptsList,\n    TranscriptsVoices,\n    TranscriptsExport,\n    TranscriptsDelete,\n  },\n\n  setup(): { dispatch: Function } {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  created() {\n    this.dispatch(contributors.init())\n    this.dispatch(transcripts.init())\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/transcripts/components/Delete.vue",
    "content": "<template>\n  <div v-if=\"state.transcripts.length > 0\">\n    <podlove-button variant=\"secondary\" size=\"small\" @click=\"openModal()\">Clear</podlove-button>\n    <modal :open=\"modalVisible\" @close=\"closeModal()\">\n      <div class=\"sm:flex sm:items-start\">\n        <div\n          class=\"\n            mx-auto\n            flex-shrink-0 flex\n            items-center\n            justify-center\n            h-12\n            w-12\n            rounded-full\n            bg-red-100\n            sm:mx-0 sm:h-10 sm:w-10\n          \"\n        >\n          <exclamation-icon class=\"h-6 w-6 text-red-600\" aria-hidden=\"true\" />\n        </div>\n        <div class=\"mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left\">\n          <DialogTitle as=\"h3\" class=\"text-lg leading-6 font-medium text-gray-900\">\n            Clear Transcript\n          </DialogTitle>\n          <div class=\"mt-2\">\n            <p class=\"text-sm text-gray-500\">\n              {{ __('Are you sure you want to clear your transcript?', 'podlove-podcasting-plugin-for-wordpress') }}\n            </p>\n          </div>\n        </div>\n      </div>\n      <div class=\"mt-5 sm:mt-4 sm:flex sm:flex-row-reverse\">\n        <podlove-button class=\"sm:ml-3 sm:w-auto sm:text-sm\" variant=\"danger\" @click=\"deleteTranscripts()\">{{ __('Clear', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n        <podlove-button class=\"sm:ml-3 sm:w-auto sm:text-sm\" @click=\"closeModal()\">{{ __('Cancel', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n      </div>\n    </modal>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { deleteTranscripts } from '@store/transcripts.store'\nimport { defineComponent } from '@vue/runtime-core'\nimport { ExclamationTriangleIcon as ExclamationIcon } from '@heroicons/vue/24/outline'\nimport { DialogTitle } from '@headlessui/vue'\n\nimport selectors from '@store/selectors'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport Modal from '@components/modal/Modal.vue'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Modal,\n    ExclamationIcon,\n    DialogTitle\n  },\n\n  data() {\n    return {\n      modalVisible: false,\n    }\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        transcripts: selectors.transcripts.list,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    deleteTranscripts() {\n      this.closeModal()\n      this.dispatch(deleteTranscripts())\n    },\n\n    openModal() {\n      this.modalVisible = true\n    },\n\n    closeModal() {\n      this.modalVisible = false\n    },\n  },\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/transcripts/components/Export.vue",
    "content": "<template>\n  <div v-if=\"state.transcripts.length > 0\">\n    <popover>\n      <template v-slot:trigger>\n        <podlove-button variant=\"secondary\" size=\"small\" tabindex=\"-1\">{{ __('Export', 'podlove-podcasting-plugin-for-wordpress') }}</podlove-button>\n      </template>\n      <template v-slot:content>\n        <div\n          class=\"bg-white p-7 pb-1 -translate-x-full z-10 mt-3 transform ml-16 rounded-lg shadow-lg\"\n        >\n          <a\n            v-for=\"(exportType, index) in exportTypes\"\n            :key=\"`type-${index}`\"\n            :download=\"exportType.file\"\n            :href=\"`${state.baseUrl}/?p=${state.post}&podlove_transcript=${exportType.type}`\"\n            class=\"\n              flex\n              items-center\n              p-2\n              -m-3\n              transition\n              duration-150\n              ease-in-out\n              rounded-lg\n              cursor-pointer\n              bg-gray-100\n              hover:bg-indigo-100\n              focus:outline-none\n              focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-50\n              mb-4\n            \"\n          >\n            <div class=\"text-sm font-medium text-gray-900 truncate w-full mr-2\">\n              {{ exportType.title }}\n            </div>\n            <div class=\"text-sm text-gray-500\">{{ exportType.ending }}</div>\n          </a>\n        </div>\n      </template>\n    </popover>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport selectors from '@store/selectors'\nimport { defineComponent } from '@vue/runtime-core'\nimport { mapAppState } from '@store/vue'\n\nimport Popover from '@components/popover/Popover.vue'\nimport PodloveButton from '@components/button/Button.vue'\n\nconst exportTypes = [\n  {\n    title: 'Export webvtt',\n    type: 'webvtt',\n    file: 'transcript.webvtt',\n    ending: '.webvtt',\n  },\n  {\n    title: 'Export json (flat)',\n    type: 'json',\n    file: 'transcript.json',\n    ending: '.json',\n  },\n  {\n    title: 'Export json (grouped)',\n    type: 'json_grouped',\n    file: 'transcript.json',\n    ending: '.json',\n  },\n  {\n    title: 'Export xml',\n    type: 'xml',\n    file: 'transcript.xml',\n    ending: '.xml',\n  },\n]\n\nexport default defineComponent({\n  components: {\n    Popover,\n    PodloveButton,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        post: selectors.post.id,\n        baseUrl: selectors.runtime.baseUrl,\n        transcripts: selectors.transcripts.list,\n      }),\n    }\n  },\n\n  data() {\n    return {\n      exportTypes,\n    }\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/transcripts/components/Import.vue",
    "content": "<template>\n  <form ref=\"importForm\" class=\"cursor-pointer\">\n    <div class=\"grid grid-cols-2\">\n      <div>\n        <podlove-button variant=\"primary\" @click=\"simulateImportClick\" class=\"ml-1\">\n          <upload-icon class=\"-ml-0.5 mr-2 h-4 w-4\" aria-hidden=\"true\" /> {{ __('Import Transcript', 'podlove-podcasting-plugin-for-wordpress') }}\n        </podlove-button>\n        <input ref=\"import\" accept=\"text/vtt\" type=\"file\" @change=\"handleImport\" class=\"hidden\" key=\"\"/>\n      </div>\n      <div>\n        <podlove-button variant=\"primary\" @click=\"importTranscriptFromAsset\" class=\"ml-1\">\n          {{ __('Get From Asset', 'podlove-podcasting-plugin-for-wordpress') }}\n        </podlove-button>\n      </div>\n    </div>\n  </form>\n</template>\n\n<script lang=\"ts\">\nimport { get } from 'lodash'\nimport { defineComponent } from '@vue/runtime-core'\nimport { CloudArrowUpIcon as UploadIcon, DocumentTextIcon } from '@heroicons/vue/24/outline'\n\nimport PodloveButton from '@components/button/Button.vue'\nimport { importTranscripts, importTranscriptFromAsset } from '@store/transcripts.store'\nimport { injectAppDispatch } from '@store/vue'\n\nexport default defineComponent({\n  props: {\n    outlet: {\n      type: String,\n      default: 'header'\n    }\n  },\n\n  components: {\n    PodloveButton, UploadIcon, DocumentTextIcon\n  },\n\n  setup() {\n    return {\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  methods: {\n    simulateImportClick() {\n      ;(this.$refs.import as HTMLInputElement).click()\n    },\n    importTranscriptFromAsset() {\n      this.dispatch(importTranscriptFromAsset())\n    },\n    handleImport() {\n      const fileInput = this.$refs.import as HTMLInputElement\n\n      if (!fileInput) {\n        return\n      }\n\n      try {\n        const reader: any = new FileReader()\n\n        reader.onload = (event: any) => {\n          this.dispatch(importTranscripts(event.target.result))\n        }\n\n        reader\n          .readAsText(get(fileInput, ['files', 0], ''))(\n            // reset import element\n            this.$refs.importForm as HTMLFormElement\n          )\n          .reset()\n      } catch (err) {}\n    },\n  },\n})\n</script>\n\n<style>\n</style>\n"
  },
  {
    "path": "client/src/modules/transcripts/components/List.vue",
    "content": "<template>\n  <div class=\"h-96 p-2 overflow-x-auto\" v-if=\"transcripts.length > 0\">\n    <div\n      class=\"flex mb-2\"\n      v-for=\"(transcript, sindex) in transcripts\"\n      :key=\"`transcript-${sindex}`\"\n    >\n      <div class=\"mr-2 w-12 text-gray-400 select-none\">\n        <img\n          class=\"w-12 h-12 rounded\"\n          v-if=\"transcript?.voice?.avatar\"\n          :src=\"transcript?.voice?.avatar\"\n        />\n        <avatar v-else />\n      </div>\n      <div class=\"w-full font-light text-sm mr-2\">\n        <span class=\"block font-bold\">{{ transcript?.voice?.name }}</span>\n        <span class=\"flex justify-between\">\n          <span>\n            <span\n              class=\"mr-1\"\n              v-for=\"(content, cindex) in transcript.content\"\n              :key=\"`transcript-${sindex}-content-${cindex}`\"\n            >\n              {{ content.text }}\n            </span>\n          </span>\n          <span class=\"ml-1 font-mono select-none\">\n            {{ formatTime(transcript.content[0].start) }}\n          </span>\n        </span>\n      </div>\n    </div>\n  </div>\n  <div v-else class=\"text-center h-96 flex items-center justify-center flex-col\">\n    <document-text-icon class=\"mx-auto h-12 w-12 text-gray-400\" />\n\n    <h3 class=\"mt-2 text-sm font-medium text-gray-900\">{{ __('No transcript yet', 'podlove-podcasting-plugin-for-wordpress') }}</h3>\n    <p class=\"mt-1 text-sm text-gray-500\">{{ __('Get started by importing a transcript.', 'podlove-podcasting-plugin-for-wordpress') }}</p>\n    <div class=\"mt-6\">\n      <transcripts-import outlet=\"content\" class=\"mr-1\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\nimport { last, dropRight, get } from 'lodash'\nimport selectors from '@store/selectors'\nimport { DocumentTextIcon } from '@heroicons/vue/24/outline'\nimport { mapAppState } from '@store/vue'\n\nimport Avatar from '@components/icons/Avatar.vue'\n\nimport { PodloveTranscript } from '../../../types/transcripts.types'\nimport { PodloveContributor } from '../../../types/contributors.types'\n\nimport TranscriptsImport from './Import.vue'\nimport Timestamp from '@lib/timestamp'\n\ninterface TranscriptGroup {\n  voiceId: string\n  content: {\n    text: string\n    start: number\n    end: number\n  }[]\n}\n\ninterface Transcript extends TranscriptGroup {\n  voice: {\n    avatar?: string\n    name: string\n  }\n}\n\ntype VoiceMap = Record<string, { id: string; name: string; avatar: string }>\n\nexport default defineComponent({\n  components: {\n    Avatar,\n    TranscriptsImport,\n    DocumentTextIcon,\n  },\n\n  setup() {\n    return {\n      state: mapAppState({\n        transcripts: selectors.transcripts.list,\n        contributors: selectors.contributors.contributors,\n        voices: selectors.transcripts.voices,\n      }),\n    }\n  },\n\n  computed: {\n    voices(): VoiceMap {\n      return this.state.contributors.reduce<VoiceMap>(\n        (result, contributor: PodloveContributor) => {\n          const voice = this.state.voices.find(\n            (voice: { voice: string; contributor: string }) => voice.contributor === contributor.id\n          )?.voice\n\n          if (!voice) {\n            return result\n          }\n\n          return {\n            ...result,\n            [voice]: {\n              id: contributor.id,\n              name: contributor.realname,\n              avatar: contributor.avatar_url,\n            },\n          }\n        },\n        {}\n      )\n    },\n\n    transcripts(): Transcript[] {\n      return this.state.transcripts\n        .reduce<TranscriptGroup[]>((result, transcript: PodloveTranscript) => {\n          const lastTranscript = last(result)\n          if (lastTranscript && lastTranscript.voiceId === transcript.voice) {\n            return [\n              ...dropRight(result),\n              {\n                ...lastTranscript,\n                voiceId: transcript.voice,\n                content: [\n                  ...lastTranscript.content,\n                  {\n                    text: transcript.text,\n                    start: transcript.start_ms,\n                    end: transcript.end_ms,\n                  },\n                ],\n              },\n            ]\n          }\n\n          return [\n            ...result,\n            {\n              voiceId: transcript.voice,\n              content: [\n                {\n                  text: transcript.text,\n                  start: transcript.start_ms,\n                  end: transcript.end_ms,\n                },\n              ],\n            },\n          ]\n        }, [])\n        .map((transcript: TranscriptGroup): Transcript => ({\n          ...transcript,\n          voice: get(this.voices, [transcript.voiceId], { name: transcript.voiceId }),\n        }))\n    },\n  },\n\n  methods: {\n    formatTime(value: number): string {\n      return new Timestamp(value).prettyShort\n    },\n  },\n})\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/src/modules/transcripts/components/Voices.vue",
    "content": "<template>\n  <div v-if=\"state.voices.length > 0\">\n    <podlove-button variant=\"secondary\" size=\"small\" @click=\"openVoices()\">{{\n      __('Voices', 'podlove-podcasting-plugin-for-wordpress')\n    }}</podlove-button>\n    <modal :open=\"modalOpen\" @close=\"closeVoices()\">\n      <div class=\"border-gray-200 border-b pb-2 px-4 -mx-6 mb-4\">\n        <h3 class=\"text-lg leading-6 font-medium text-gray-900\">\n          {{ __('Transcript Voices', 'podlove-podcasting-plugin-for-wordpress') }}\n        </h3>\n      </div>\n      <div\n        v-for=\"(voice, vindex) in state.voices\"\n        :key=\"`voice-${vindex}`\"\n        class=\"w-full py-2 px-4\"\n        :class=\"{ 'bg-white': Number(vindex) % 2 }\"\n      >\n        <label for=\"country\" class=\"block text-sm font-medium text-gray-700\">{{\n          voice.voice\n        }}</label>\n        <select\n          :value=\"voice.contributor\"\n          class=\"mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\"\n          @change=\"updateContributor(voice.voice, $event)\"\n        >\n          <option value=\"0\"></option>\n          <option\n            v-for=\"(contributor, kindex) in sortedContributors\"\n            :key=\"`voice-${vindex}-contributor-${kindex}`\"\n            :value=\"contributor.id\"\n          >\n            {{ contributor.publicname || contributor.realname || contributor.nickname }}\n          </option>\n        </select>\n      </div>\n    </modal>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from '@vue/runtime-core'\nimport selectors from '@store/selectors'\nimport { updateVoice } from '@store/transcripts.store'\nimport Modal from '@components/modal/Modal.vue'\nimport PodloveButton from '@components/button/Button.vue'\nimport { PodloveContributor } from '../../../types/contributors.types'\nimport { injectAppDispatch, mapAppState } from '@store/vue'\n\nexport default defineComponent({\n  components: {\n    PodloveButton,\n    Modal,\n  },\n  data() {\n    return {\n      modalOpen: false,\n    }\n  },\n  setup() {\n    return {\n      state: mapAppState({\n        contributors: selectors.contributors.contributors,\n        voices: selectors.transcripts.voices,\n      }),\n      dispatch: injectAppDispatch(),\n    }\n  },\n\n  computed: {\n    sortedContributors(): PodloveContributor[] {\n      return this.state.contributors.sort((a: PodloveContributor, b: PodloveContributor) => {\n        const aName = a.publicname || a.realname || a.nickname\n        const bName = b.publicname || b.realname || b.nickname\n        return aName.localeCompare(bName)\n      })\n    },\n  },\n\n  methods: {\n    updateContributor(voice: string, event: Event) {\n      const contributor = (event.target as HTMLInputElement).value\n      this.dispatch(updateVoice({ voice, contributor }))\n    },\n\n    openVoices() {\n      this.modalOpen = true\n    },\n\n    closeVoices() {\n      this.modalOpen = false\n    },\n  },\n})\n</script>\n"
  },
  {
    "path": "client/src/modules/transcripts/index.ts",
    "content": "import Transcripts from './Transcripts.vue';\n\nexport default Transcripts;\n"
  },
  {
    "path": "client/src/plugins/translations.ts",
    "content": "import { get } from 'lodash'\nimport { App } from 'vue'\n\ndeclare module '@vue/runtime-core' {\n  interface ComponentCustomProperties {\n    __: (translation: string, domain: string) => string;\n  }\n}\n\nconst translate = get(window, ['wp', 'i18n', '__'], (translation: string) => translation)\n\nexport const __ = translate;\n\nexport default {\n  install(app: App) {\n    app.config.globalProperties['__'] = (translation: string, domain: string) => translate(translation, domain)\n  },\n}\n"
  },
  {
    "path": "client/src/sagas/admin.sagas.ts",
    "content": "import { fork, select, put, takeEvery } from 'redux-saga/effects'\nimport { PodloveApiClient } from '@lib/api'\nimport { createApi } from './api'\nimport * as adminStore from '@store/admin.store'\nimport { takeFirst } from './helper'\nimport { selectors } from '@store'\n\ninterface AdminData {\n    bannerHide: boolean | null\n    type: string | null\n    feedUrl: string | null\n}\n\nfunction* adminSaga() {\n    const apiClient: PodloveApiClient = yield createApi()\n    yield fork(initialize, apiClient)\n    yield takeEvery(adminStore.UPDATE_TYPE, save, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n    const { result }: { result: AdminData } = yield api.get('admin/onboarding')\n\n    if (result) {\n        yield put(adminStore.set(result))\n    }\n}\n\nfunction* save(api: PodloveApiClient, action: {type: string}) {\n    const typeValue: string = yield select(selectors.admin.type)\n\n    yield api.put('admin/onboarding', {type: typeValue})\n}\n\nexport default function() {\n    return function* () {\n        yield takeFirst(adminStore.INIT, adminSaga)\n    }\n}"
  },
  {
    "path": "client/src/sagas/api.ts",
    "content": "import { call, select } from 'redux-saga/effects'\nimport { podlove } from '../lib/api'\nimport { selectors, store } from '@store'\nimport { notify } from '@store/notification.store'\nimport { waitFor } from './helper'\n\nexport function* createApi() {\n  yield call(waitFor, selectors.lifecycle.bootstrapped)\n  const base: string = yield select(selectors.runtime.base)\n  const nonce: string = yield select(selectors.runtime.nonce)\n  const auth: string = yield select(selectors.runtime.auth)\n  const bearer: string = yield select(selectors.runtime.bearer)\n\n  const errorHandler = function (errorData: any) {\n    let message = 'An error occurred'\n\n    if (typeof errorData === 'string') {\n      message = errorData\n    } else if (errorData && typeof errorData === 'object') {\n      if (errorData.code && errorData.message) {\n        message = `${errorData.code}: ${errorData.message}`\n      } else {\n        message = errorData.message || errorData.code || 'An error occurred'\n      }\n    }\n\n    store.dispatch(notify({ type: 'error', message }))\n  }\n\n  return podlove({ base, version: 'v2', nonce, auth, bearer, errorHandler })\n}\n"
  },
  {
    "path": "client/src/sagas/auphonic.api.ts",
    "content": "import { selectors, store } from '@store'\nimport { notify } from '@store/notification.store'\nimport { select } from 'redux-saga/effects'\nimport { auphonic } from '../lib/auphonic.api'\n\nexport function* createApi() {\n  const base: string = 'https://auphonic.com/api'\n  const bearer: string = yield select(selectors.auphonic.token)\n\n  const errorHandler = function (response: any) {\n    store.dispatch(notify({ type: 'error', message: `Auphonic: ${response.error_message}` }))\n  }\n\n  return auphonic({ base, bearer, errorHandler })\n}\n"
  },
  {
    "path": "client/src/sagas/auphonic.sagas.ts",
    "content": "import * as auphonic from '@store/auphonic.store'\nimport * as episode from '@store/episode.store'\nimport * as progress from '@store/progress.store'\nimport * as plus from '@store/plus.store'\nimport {\n  takeFirst,\n  createAndWatchProgressChannel,\n  ProgressPayload,\n  createProgressHandler,\n} from '../sagas/helper'\nimport { delay, put, take, fork, takeEvery, select, all, call, race } from 'redux-saga/effects'\nimport { createApi } from '../sagas/api'\nimport { createApi as createAuphonicApi } from '../sagas/auphonic.api'\nimport { PodloveApiClient } from '@lib/api'\nimport { AuphonicApiClient } from '@lib/auphonic.api'\nimport { selectors } from '@store'\nimport { v4 as uuidv4 } from 'uuid'\nimport { State } from '../store'\nimport { get } from 'lodash'\nimport Timestamp from '@lib/timestamp'\nimport { createErrorResponse, createTransferErrorResponse, getApiErrorMessage } from '@lib/errorHandling'\nimport { determineTransferStatus, countSuccessfulResults } from '@lib/statusHelpers'\nimport { Channel } from 'redux-saga'\nimport { verifyAll } from './mediafiles.verification.sagas'\n\nfunction* auphonicSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n  yield takeEvery(auphonic.UPDATE_FILE_SELECTION, handleFileSelection)\n  yield takeEvery(auphonic.SET_SERVICE_FILES, handleServiceFilesAvailable)\n  yield put(plus.init())\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const { result }: { result: string } = yield api.get(`auphonic/token`)\n\n  if (result) {\n    yield put(auphonic.setToken(result))\n    yield fork(initializeAuphonicApi)\n\n    yield takeEvery(auphonic.SET_PRODUCTION, initializeWebhookConfig, api)\n    yield takeEvery(auphonic.UPDATE_WEBHOOK, updateWebhookConfig, api)\n\n    yield takeEvery(auphonic.SET_PRODUCTION, memorizeSelectedProduction, api)\n    yield takeEvery(auphonic.DESELECT_PRODUCTION, forgetSelectedProduction, api)\n\n    yield takeEvery(auphonic.SET_PRESET, memorizeSelectedPreset)\n\n    yield takeEvery(auphonic.START_PRODUCTION, markProductionAsRunning, api)\n    yield takeEvery(auphonic.STOP_POLLING, markProductionAsNotRunning, api)\n    yield takeEvery(auphonic.DESELECT_PRODUCTION, markProductionAsNotRunning, api)\n\n    yield takeEvery(auphonic.TRIGGER_PLUS_TRANSFER, handleTriggerPlusTransfer, api)\n    yield takeEvery(auphonic.LOAD_PLUS_TRANSFER_STATUS, handleLoadPlusTransferStatus, api)\n    yield takeEvery(auphonic.SET_PLUS_TRANSFER_STATUS, handlePlusTransferStatusChange, api)\n  }\n}\n\nfunction* initializeAuphonicApi() {\n  const auphonicApi: AuphonicApiClient = yield createAuphonicApi()\n\n  const {\n    result: { data: presets },\n  } = yield auphonicApi.get(`presets.json`)\n\n  const {\n    result: { data: productions },\n  } = yield auphonicApi.get(`productions.json`, { limit: 50, minimal_data: true })\n\n  let {\n    result: { data: services },\n  } = yield auphonicApi.get(`services.json`)\n\n  yield put(auphonic.setProductions(productions))\n  yield put(auphonic.setPresets(presets))\n  yield put(\n    auphonic.setServices([\n      {\n        uuid: 'url',\n        display_name: 'From URL',\n        email: '',\n        incoming: true,\n        outgoing: false,\n        type: 'url',\n      },\n      {\n        uuid: 'file',\n        display_name: 'Upload from computer',\n        email: '',\n        incoming: true,\n        outgoing: false,\n        type: 'file',\n      },\n      ...services,\n    ])\n  )\n\n  yield call(maybeRestoreProductionSelection)\n  yield call(maybeRestorePresetSelection)\n  yield put(auphonic.initDone())\n\n  yield takeEvery(auphonic.CREATE_PRODUCTION, handleCreateProduction, auphonicApi)\n  yield takeEvery(\n    auphonic.CREATE_MULTITRACK_PRODUCTION,\n    handleCreateMultitrackProduction,\n    auphonicApi\n  )\n  yield takeEvery(auphonic.selectService, fetchServiceFiles, auphonicApi)\n  yield takeEvery(auphonic.saveProduction, handleSaveProduction, auphonicApi)\n  yield takeEvery(auphonic.startProduction, handleStartProduction, auphonicApi)\n  yield takeEvery(auphonic.deselectProduction, handleDeselectProduction, auphonicApi)\n  yield takeEvery(auphonic.removeTrack, handleRemoveTrack, auphonicApi)\n\n  // poll production updates while production is running\n  // TODO: start polling when loading a production that is in production\n  yield call(pollWatcherSaga, auphonicApi)\n}\n\nfunction* pollWatcherSaga(auphonicApi: AuphonicApiClient) {\n  let isAuphonicProductionRunning: boolean = yield select(\n    selectors.episode.isAuphonicProductionRunning\n  )\n\n  // Start polling on page load if the production is already running\n  if (isAuphonicProductionRunning) {\n    yield race([call(pollProductionSaga, auphonicApi), take(auphonic.STOP_POLLING)])\n  }\n\n  while (true) {\n    yield take(auphonic.START_POLLING)\n    yield race([call(pollProductionSaga, auphonicApi), take(auphonic.STOP_POLLING)])\n  }\n}\n\nfunction* pollProductionSaga(auphonicApi: AuphonicApiClient): any {\n  while (true) {\n    let uuid: string = yield select(selectors.auphonic.productionId)\n\n    if (!uuid) {\n      yield put(auphonic.stopPolling())\n    }\n\n    let {\n      result: { data: production },\n    } = yield auphonicApi.get(`production/${uuid}.json`)\n\n    yield put(auphonic.setProduction(production))\n\n    // DONE\n    if (production.status == 3) {\n      yield put(episode.update({ prop: 'slug', value: production.output_basename }))\n\n      // NOTE: is there a race condition here? because the file transfer uses\n      // the slug form the database\n\n      // trigger PLUS transfer when production finishes\n      const plusFeatures = yield select(selectors.plus.features)\n      if (plusFeatures.fileStorage) {\n        yield put(auphonic.triggerPlusTransfer({ production_uuid: production.uuid }))\n      }\n    }\n\n    // see https://auphonic.com/api/info/production_status.json\n    const in_progress_status = [0, 1, 4, 5, 6, 7, 8, 12, 13, 14]\n    if (!in_progress_status.includes(production.status)) {\n      yield put(auphonic.stopPolling())\n    }\n\n    yield delay(2500)\n  }\n}\n\nfunction* handleDeselectProduction(auphonicApi: AuphonicApiClient) {\n  const {\n    result: { data: productions },\n  } = yield auphonicApi.get(`productions.json`, { limit: 10, minimal_data: true })\n  yield put(auphonic.setProductions(productions))\n}\n\nfunction* handleRemoveTrack(\n  auphonicApi: AuphonicApiClient,\n  action: { type: string; payload: any }\n) {\n  let uuid: string = yield select(selectors.auphonic.productionId)\n\n  yield auphonicApi.delete(`production/${uuid}/multi_input_files/${action.payload}.json`)\n}\n\nfunction* handleStartProduction(\n  auphonicApi: AuphonicApiClient,\n  action: { type: string; payload: any }\n) {\n  const uuid = action.payload.uuid\n\n  yield call(handleSaveProduction, auphonicApi, {\n    type: auphonic.SAVE_PRODUCTION,\n    payload: { uuid: uuid },\n  })\n\n  const webhookConfig: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig)\n  const isWebhookEnabled: boolean = yield select(selectors.auphonic.publishWhenDone)\n  const baseUrl: String = yield select(selectors.runtime.baseUrl)\n  const postId: Number = yield select(selectors.post.id)\n\n  const webhookUrl =\n    baseUrl + '/?podlove-auphonic-production=' + postId + '&authkey=' + webhookConfig?.authkey\n  const productionPayload = {\n    webhook: webhookConfig && isWebhookEnabled ? webhookUrl : '',\n  }\n\n  // update webhook config\n  const {\n    result: { data: _production },\n  } = yield auphonicApi.post(`production/${uuid}.json`, productionPayload)\n\n  // TODO: for productions with webhook enabled, should I explicitly re-fetch\n  // the episode when production is done (or poll for a bit)? Otherwise backend\n  // and frontend might be out of sync because the webhook overrides some\n  // episode data.\n\n  // start production\n  const response: { result: any; error: any } = yield auphonicApi.post(\n    `production/${uuid}/start.json`,\n    {}\n  )\n\n  if (response.result) {\n    yield put(auphonic.setProduction(response.result.data))\n  } else {\n    console.warn(response.error.error_message)\n  }\n\n  yield put(auphonic.startPolling())\n}\n\nfunction* handleSaveTrack(\n  auphonicApi: AuphonicApiClient,\n  uuid: String,\n  trackWrapper: any,\n  handleProgress: any\n) {\n  let payload = trackWrapper.payload\n\n  const id_old = payload.id\n  const id_new = payload.id_new\n\n  const needs_upload = !!trackWrapper.upload?.file\n\n  delete payload.id_new\n  payload.id = id_new\n\n  const progressHandler = handleProgress(payload.id)\n\n  switch (trackWrapper.state) {\n    case 'edited':\n      yield auphonicApi.post(`production/${uuid}/multi_input_files/${id_old}.json`, payload)\n      if (needs_upload) {\n        yield auphonicApi.upload(`production/${uuid}/upload.json`, trackWrapper.upload, {\n          hooks: { onUploadProgress: progressHandler },\n        })\n      }\n      break\n    case 'new':\n      yield auphonicApi.post(`production/${uuid}.json`, {\n        multi_input_files: [trackWrapper.payload],\n      })\n      if (needs_upload) {\n        yield auphonicApi.upload(`production/${uuid}/upload.json`, trackWrapper.upload, {\n          hooks: { onUploadProgress: progressHandler },\n        })\n      }\n      break\n  }\n}\n\ntype PreparedFileSelection = {\n  service?: string | null\n  value?: string | null\n}\n\nconst prepareFile = (selection: auphonic.FileSelection): PreparedFileSelection => {\n  if (!selection) {\n    return {}\n  }\n\n  switch (selection.currentServiceSelection) {\n    case 'url':\n      return { service: 'url', value: selection.urlValue }\n    case 'file':\n      return { service: 'file', value: selection.fileValue }\n    default:\n      return { service: selection.currentServiceSelection, value: selection.fileSelection }\n  }\n}\n\nfunction getFileSelectionsForSingleTrack(state: State): PreparedFileSelection {\n  const selections = get(state, ['auphonic', 'file_selections'])\n  const production_uuid = get(state, ['auphonic', 'production', 'uuid'], '')\n\n  return prepareFile(get(selections, production_uuid))\n}\n\nfunction getFileSelectionsForMultiTrack(state: State): PreparedFileSelection[] {\n  const selections = get(state, ['auphonic', 'file_selections'])\n  const production_uuid = get(state, ['auphonic', 'production', 'uuid'], '')\n  const tracks = get(state, ['auphonic', 'tracks'], [])\n\n  //@ts-ignore\n  return tracks.reduce((agg, _track, index) => {\n    //@ts-ignore\n    agg.push(prepareFile(get(selections, `${production_uuid}_t${index}`)))\n    return agg\n  }, [])\n}\n\nfunction getTracksPayload(state: State): any {\n  const isMultitrack = get(state, ['auphonic', 'production', 'is_multitrack'], false)\n  const tracks = get(state, ['auphonic', 'tracks'], [])\n\n  if (!isMultitrack) {\n    return []\n  }\n\n  const fileSelections = getFileSelectionsForMultiTrack(state)\n\n  return tracks\n    .map((track, index) => {\n      const state = track.save_state\n\n      if (state == 'unchanged') {\n        return {}\n      }\n\n      let upload = {}\n\n      // FIXME: currently service is always url when selecting an existing production\n      let fileReference = {}\n      if (fileSelections[index].service == 'url') {\n        fileReference = {\n          input_file: fileSelections[index].value,\n        }\n      } else if (fileSelections[index].service == 'file') {\n        upload = {\n          track_id: track.identifier_new,\n          file: fileSelections[index].value,\n        }\n      } else {\n        fileReference = {\n          service: fileSelections[index].service,\n          input_file: fileSelections[index].value,\n        }\n      }\n\n      return {\n        state,\n        upload,\n        payload: {\n          type: 'multitrack',\n          id: track.identifier,\n          id_new: track.identifier_new,\n          ...fileReference,\n          algorithms: {\n            denoise: track.noise_and_hum_reduction,\n            filtering: track.filtering,\n            backforeground: track.fore_background,\n          },\n        },\n      }\n    })\n    .filter((t) => Object.keys(t).length > 0)\n}\n\nfunction getProductionPayload(state: State): object {\n  let payload = get(state, ['auphonic', 'productionPayload'], {})\n\n  // remove output_files from payload, because it doubles them\n  const { output_files, ...newPayload } = payload\n  const episode_poster = selectors.episode.effectivePoster(state)\n  const maybe_output_basename = state.episode.slug ? { output_basename: state.episode.slug } : {}\n\n  return {\n    ...newPayload,\n    ...maybe_output_basename,\n    // NOTE: image is not actually sent; it's sent as a separate upload and\n    // removed from the payload before saving metadata. reason: Auphonic may not\n    // have access to the URL here (for example in local development), so\n    // sending the file as upload is more reliable.\n    image: episode_poster,\n    metadata: {\n      ...newPayload.metadata,\n      title: state.episode.title || state.post.title,\n      subtitle: state.episode.subtitle,\n      summary: state.episode.summary,\n      artist: state.podcast.author_name,\n      album: state.podcast.title,\n      url: state.podcast.link,\n      track: state.episode.number,\n    },\n    chapters: state.chapters.chapters.map((chapter) => {\n      const payload: {\n        title: string\n        url?: string\n        start: string\n        image?: string\n      } = {\n        title: chapter.title,\n        url: chapter.href,\n        start: new Timestamp(chapter.start).pretty,\n      }\n\n      if (chapter.image) {\n        payload.image = chapter.image\n      }\n\n      return payload\n    }),\n  }\n}\n\nfunction getSaveProductionPayload(state: State): object {\n  const isMultitrack = get(state, ['auphonic', 'production', 'is_multitrack'], false)\n  const productionPayload = getProductionPayload(state)\n\n  let fileReference = {}\n  // for single track, add file selection to payload\n  if (!isMultitrack) {\n    const fileSelections = getFileSelectionsForSingleTrack(state)\n    if (fileSelections.service == 'url') {\n      fileReference = {\n        input_file: fileSelections.value,\n      }\n    } else if (fileSelections.service == 'file') {\n      fileReference = {\n        input_file: fileSelections.value,\n      }\n    } else {\n      fileReference = {\n        service: fileSelections.service,\n        input_file: fileSelections.value,\n      }\n    }\n  }\n\n  return {\n    ...productionPayload,\n    ...fileReference,\n  }\n}\n\nasync function uploadProductionImage(\n  auphonicApi: AuphonicApiClient,\n  uuid: string,\n  posterFile: string | null | undefined,\n  handleProgress: (key: string) => (progress: any) => void\n) {\n  if (!posterFile) {\n    return\n  }\n\n  const res = await fetch(posterFile)\n\n  if (!res.ok) {\n    throw new Error(`Failed to fetch production image: ${res.status} ${res.statusText}`)\n  }\n\n  const blob = await res.blob()\n\n  if (!blob.type.startsWith('image/')) {\n    throw new Error(`Invalid production image response type: ${blob.type || 'unknown'}`)\n  }\n\n  const ext = blob.type.includes('png') ? 'png' : 'jpg'\n  const filename = `image.${ext}`\n  const imageFile = new File([blob], filename, { type: blob.type })\n\n  await auphonicApi.upload(\n    `production/${uuid}/upload.json`,\n    { image: imageFile },\n    { hooks: { onUploadProgress: handleProgress('poster') } }\n  )\n}\n\nfunction shouldInlineChapterImage(imageUrl: string): boolean {\n  try {\n    const resolvedUrl = new URL(imageUrl, window.location.href)\n    const localHosts = new Set(['localhost', '127.0.0.1', '::1'])\n\n    return (\n      resolvedUrl.origin === window.location.origin ||\n      localHosts.has(resolvedUrl.hostname)\n    )\n  } catch (_error) {\n    return false\n  }\n}\n\nasync function blobToBase64(blob: Blob): Promise<string> {\n  return await new Promise((resolve, reject) => {\n    const reader = new FileReader()\n\n    reader.onloadend = () => {\n      if (typeof reader.result === 'string') {\n        const [, base64] = reader.result.split(',', 2)\n\n        if (base64) {\n          resolve(base64)\n        } else {\n          reject(new Error('Failed to extract base64 data from chapter image'))\n        }\n      } else {\n        reject(new Error('Failed to convert chapter image to base64'))\n      }\n    }\n\n    reader.onerror = () => reject(reader.error || new Error('Failed to read chapter image'))\n    reader.readAsDataURL(blob)\n  })\n}\n\nasync function inlineChapterImages(chapters: any[] | undefined): Promise<any[] | undefined> {\n  if (!chapters?.length) {\n    return chapters\n  }\n\n  return await Promise.all(\n    chapters.map(async (chapter) => {\n      if (!chapter.image || !shouldInlineChapterImage(chapter.image)) {\n        return chapter\n      }\n\n      try {\n        const response = await fetch(chapter.image)\n\n        if (!response.ok) {\n          throw new Error(`Failed to fetch chapter image: ${response.status} ${response.statusText}`)\n        }\n\n        const blob = await response.blob()\n\n        if (!blob.type.startsWith('image/')) {\n          throw new Error(`Invalid chapter image response type: ${blob.type || 'unknown'}`)\n        }\n\n        return {\n          ...chapter,\n          image: await blobToBase64(blob),\n        }\n      } catch (error) {\n        console.warn('Skipping Auphonic chapter image after inline conversion failed', error)\n\n        const { image, ...chapterWithoutImage } = chapter\n        return chapterWithoutImage\n      }\n    })\n  )\n}\n\nfunction* handleSaveProduction(\n  auphonicApi: AuphonicApiClient,\n  action: { type: string; payload: any }\n): any {\n  yield put(auphonic.startSaving())\n\n  try {\n    const uuid = action.payload.uuid\n    //@ts-ignore\n    const productionPayload = yield select(getSaveProductionPayload)\n    //@ts-ignore\n    const tracksPayload = yield select(getTracksPayload)\n\n    productionPayload.chapters = yield call(inlineChapterImages, productionPayload.chapters)\n\n    // delete all existing chapters, otherwise we append them\n    //@ts-ignore\n    yield auphonicApi.delete(`production/${uuid}/chapters.json`)\n\n    const progressChannel: Channel<ProgressPayload> = yield call(\n      createAndWatchProgressChannel,\n      progress.setProgress\n    )\n\n    const handleProgress = createProgressHandler(progressChannel)\n\n    // save multi_input_files by saving/updating each track individually\n    yield all(\n      tracksPayload.map((trackWrapper: any) =>\n        call(handleSaveTrack, auphonicApi, uuid, trackWrapper, handleProgress)\n      )\n    )\n\n    // handle single track if input_file is set\n    // FIXME: only upload when changed, see multitrack logic\n    const input_file = productionPayload.input_file\n    if (typeof input_file == 'object') {\n      yield call(\n        auphonicApi.upload,\n        `production/${uuid}/upload.json`,\n        { file: input_file },\n        { hooks: { onUploadProgress: handleProgress('singletrack') } }\n      )\n\n      delete productionPayload.input_file\n    }\n\n    // Keep poster upload best-effort so external URLs without CORS do not block saving metadata.\n    const poster_file = productionPayload.image\n\n    try {\n      yield call(uploadProductionImage, auphonicApi, uuid, poster_file, handleProgress)\n    } catch (error) {\n      console.warn('Skipping Auphonic production image upload after fetch/upload failed', error)\n    }\n\n    delete productionPayload.image\n\n    // after the tracks, update all other metadata\n    const {\n      result: { data: production },\n    } = yield auphonicApi.post(`production/${uuid}.json`, productionPayload)\n\n    yield put(auphonic.setProduction(production))\n  } finally {\n    yield put(auphonic.stopSaving())\n  }\n}\n\nfunction* fetchServiceFiles(\n  auphonicApi: AuphonicApiClient,\n  action: { type: string; payload: string }\n) {\n  const uuid = action.payload\n\n  if (uuid == 'file' || uuid == 'url') {\n    return\n  }\n\n  const { result } = yield auphonicApi.get(`service/${uuid}/ls.json`)\n\n  yield put(auphonic.setServiceFiles({ uuid, files: result.data }))\n}\n\nfunction* titleWithFallback() {\n  const episodeTitle: string = yield select(selectors.episode.title)\n  const postTitle: string = yield select(selectors.post.title)\n\n  return episodeTitle || postTitle || `New Production`\n}\n\nfunction* handleCreateProduction(auphonicApi: AuphonicApiClient) {\n  const presetUUID: string = yield select(selectors.auphonic.preset)\n  const title: string = yield titleWithFallback()\n\n  const { result } = yield auphonicApi.post(`productions.json`, {\n    preset: presetUUID,\n    metadata: { title: title },\n  })\n  const production = result.data\n\n  yield put(auphonic.setProduction(production))\n}\n\nfunction* handleCreateMultitrackProduction(auphonicApi: AuphonicApiClient) {\n  const presetUUID: string = yield select(selectors.auphonic.preset)\n  const title: string = yield titleWithFallback()\n\n  const { result } = yield auphonicApi.post(`productions.json`, {\n    preset: presetUUID,\n    metadata: { title: title },\n    is_multitrack: true,\n  })\n  const production = result.data\n\n  yield put(auphonic.setProduction(production))\n}\n\nfunction* handleServiceFilesAvailable(action: {\n  type: string\n  payload: { uuid: string; files: string[] }\n}) {\n  const currentKey: string = yield select(selectors.auphonic.currentFileSelection)\n  //@ts-ignore\n  const selection: any = yield select(selectors.auphonic.fileSelections)\n\n  // set default, but only if necessary\n  if (!selection[currentKey].fileSelection) {\n    // select first available file\n    yield put(\n      auphonic.updateFileSelection({\n        key: currentKey,\n        prop: 'fileSelection',\n        value: action.payload.files[0],\n      })\n    )\n  }\n}\n\nfunction* handleFileSelection(action: {\n  type: string\n  payload: { key: string; prop: string; value: any }\n}) {\n  const { prop, value } = action.payload\n  if (prop === 'currentServiceSelection') {\n    yield put(auphonic.selectService(value))\n  }\n}\n\nexport type WebhookConfig = {\n  authkey: String\n  enabled: boolean\n}\n\nfunction* updateWebhookConfig(api: PodloveApiClient) {\n  const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig)\n  const enabled: boolean = yield select(selectors.auphonic.publishWhenDone)\n\n  // skip if nothing changed\n  if (!config || config.enabled == enabled) {\n    return\n  }\n\n  yield put(\n    episode.update({ prop: 'auphonic_webhook_config', value: { ...config, enabled: enabled } })\n  )\n}\n\nfunction* initializeWebhookConfig(api: PodloveApiClient) {\n  const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig)\n  const enabled: boolean = yield select(selectors.auphonic.publishWhenDone)\n\n  // skip if it already exists\n  if (config && config.authkey) {\n    return\n  }\n\n  const authkey = uuidv4()\n\n  yield put(\n    episode.update({\n      prop: 'auphonic_webhook_config',\n      value: {\n        authkey,\n        enabled: enabled || false,\n      },\n    })\n  )\n}\n\nfunction* memorizeSelectedProduction(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const uuid: string = yield select(selectors.auphonic.productionId)\n\n  yield api.put(`episodes/${episodeId}`, { auphonic_production_id: uuid })\n}\n\nfunction* forgetSelectedProduction(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  yield api.put(`episodes/${episodeId}`, { auphonic_production_id: '' })\n}\n\nfunction* markProductionAsRunning(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  yield api.put(`episodes/${episodeId}`, { is_auphonic_production_running: true })\n}\n\nfunction* markProductionAsNotRunning(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  yield api.put(`episodes/${episodeId}`, { is_auphonic_production_running: false })\n}\n\nfunction* maybeRestoreProductionSelection() {\n  const episodeId: string = yield select(selectors.episode.id)\n  const memorizedProductionId: string = yield select(selectors.episode.auphonicProductionId)\n  const selectedProductionId: string = yield select(selectors.auphonic.productionId)\n  const productions: auphonic.Production[] = yield select(selectors.auphonic.productions)\n\n  if (!selectedProductionId && memorizedProductionId && episodeId) {\n    const production = productions.find((production) => production.uuid == memorizedProductionId)\n\n    if (production) {\n      yield put(auphonic.setProduction(production))\n    }\n  }\n}\n\nfunction* memorizeSelectedPreset() {\n  const preset: string = yield select(selectors.auphonic.preset)\n\n  if (localStorage) {\n    localStorage.setItem('podlove-auphonic-preset', preset)\n  }\n}\n\nfunction* maybeRestorePresetSelection() {\n  let savedPreset: string | null = null\n\n  if (localStorage) {\n    savedPreset = localStorage.getItem('podlove-auphonic-preset')\n    if (savedPreset) {\n      yield put(auphonic.setPreset(savedPreset))\n    }\n  }\n}\n\nfunction* handleTriggerPlusTransfer(\n  api: PodloveApiClient,\n  action: { type: string; payload: { production_uuid: string } }\n): any {\n  const { production_uuid } = action.payload\n  const postId: Number = yield select(selectors.post.id)\n\n  try {\n    yield put(\n      auphonic.setPlusTransferStatus({\n        production_uuid,\n        status: 'in_progress',\n      })\n    )\n\n    // Phase 1: Get transfer queue\n    const response = yield api.post(\n      `auphonic/init-plus-file-transfer/${production_uuid}/${postId}`,\n      {}\n    )\n\n    if (response.result && response.result.success && response.result.transfer_queue) {\n      const transferQueue = response.result.transfer_queue\n\n      if (transferQueue.length === 0) {\n        yield put(\n          auphonic.setPlusTransferStatus({\n            production_uuid,\n            status: 'completed',\n            files: [],\n          })\n        )\n        return\n      }\n\n      // Phase 2: Process files sequentially\n      yield call(processTransferQueue, api, production_uuid, postId, transferQueue)\n    } else {\n             yield put(\n         auphonic.setPlusTransferStatus({\n           production_uuid,\n           status: 'failed',\n           errors: getApiErrorMessage(response, 'Failed to initialize transfer'),\n         })\n       )\n    }\n  } catch (error: any) {\n    yield put(\n      auphonic.setPlusTransferStatus({\n        production_uuid,\n        status: 'failed',\n        errors: error.message || 'Failed to trigger transfer',\n      })\n    )\n  }\n}\n\n// Helper function to get remaining pending files\nfunction getPendingFiles(transferQueue: any[], completedCount: number): any[] {\n  return transferQueue.slice(completedCount).map(file => ({\n    success: null,\n    status: 'pending' as const,\n    filename: file.filename,\n    download_url: file.download_url,\n    message: 'Waiting to transfer...'\n  }))\n}\n\n// Helper function to create file with processing state\nfunction createProcessingFile(file: any): any {\n  return {\n    success: null,\n    status: 'processing' as const,\n    filename: file.filename,\n    download_url: file.download_url,\n    message: 'Transferring...'\n  }\n}\n\n// Helper function to update file result with proper status\nfunction updateFileResult(result: any): any {\n  return {\n    ...result,\n    status: result.success ? 'completed' as const : 'failed' as const\n  }\n}\n\nfunction* processTransferQueue(\n  api: PodloveApiClient,\n  production_uuid: string,\n  postId: Number,\n  transferQueue: any[]\n): any {\n  const production: auphonic.Production | null = yield select(selectors.auphonic.production)\n  const productionChangeTime = production?.change_time || null\n  let transferredFiles = 0\n  let hasErrors = false\n  const transferResults: any[] = []\n\n    // Show initial transfer UI with all files as pending\n  const initialFiles = transferQueue.map(file => ({\n    success: null,\n    status: 'pending' as const,\n    filename: file.filename,\n    download_url: file.download_url,\n    message: 'Waiting to transfer...'\n  }))\n\n  yield put(\n    auphonic.setPlusTransferStatus({\n      production_uuid,\n      status: 'in_progress',\n      files: initialFiles,\n    })\n  )\n\n  for (let i = 0; i < transferQueue.length; i++) {\n    const file = transferQueue[i]\n\n    // Mark current file as processing\n    const filesWithProcessing = [\n      ...transferResults.map(updateFileResult),\n      createProcessingFile(file),\n      ...getPendingFiles(transferQueue, i + 1)\n    ]\n\n    yield put(\n      auphonic.setPlusTransferStatus({\n        production_uuid,\n        status: 'in_progress', // Keep as in_progress during transfer\n        files: filesWithProcessing,\n      })\n    )\n\n    try {\n      const result = yield call(transferFile, api, production_uuid, postId, file)\n      transferResults.push(result)\n\n      if (result.success) {\n        transferredFiles++\n      } else {\n        hasErrors = true\n      }\n\n      // Update UI after each file transfer (but still in_progress if more files remain)\n      const isLastFile = i === transferQueue.length - 1\n      const currentStatus = isLastFile ? determineTransferStatus(hasErrors, transferredFiles) : 'in_progress'\n\n      yield put(\n        auphonic.setPlusTransferStatus({\n          production_uuid,\n          status: currentStatus,\n          files: [...transferResults.map(updateFileResult), ...getPendingFiles(transferQueue, transferResults.length)],\n        })\n      )\n    } catch (error: any) {\n      hasErrors = true\n      const errorResult = createTransferErrorResponse(file, error.message)\n      transferResults.push(errorResult)\n      console.error('Error transferring file:', error)\n\n      // Update UI after error (but still in_progress if more files remain)\n      const isLastFile = i === transferQueue.length - 1\n      const currentStatus = isLastFile ? determineTransferStatus(hasErrors, transferredFiles) : 'in_progress'\n\n      yield put(\n        auphonic.setPlusTransferStatus({\n          production_uuid,\n          status: currentStatus,\n          files: [...transferResults.map(updateFileResult), ...getPendingFiles(transferQueue, transferResults.length)],\n        })\n      )\n    }\n  }\n\n  const finalStatus = determineTransferStatus(hasErrors, transferredFiles)\n\n  // Store final status in backend for page reload persistence\n  try {\n    const payload: any = {\n      status: finalStatus,\n      files: transferResults\n    }\n\n    if (finalStatus === 'completed' && productionChangeTime) {\n      payload.change_time = productionChangeTime\n    }\n\n    // Only include errors parameter if there are errors\n    if (hasErrors) {\n      if (transferredFiles === 0) {\n        payload.errors = 'All file transfers failed'\n      } else {\n        const failedCount = transferResults.length - transferredFiles\n        payload.errors = `${failedCount} of ${transferResults.length} file transfers failed`\n      }\n    }\n\n    yield api.post(`auphonic/set-plus-transfer-status/${production_uuid}/${postId}`, payload)\n  } catch (error: any) {\n    console.error('Failed to store final transfer status:', error)\n  }\n\n  // Final UI update with only completed results (no pending files)\n  yield put(\n    auphonic.setPlusTransferStatus({\n      production_uuid,\n      status: finalStatus,\n      files: transferResults.map(updateFileResult),\n    })\n  )\n\n  if (finalStatus === 'completed' && productionChangeTime) {\n    yield put(\n      episode.update({\n        prop: 'auphonic_plus_transfer_change_time',\n        value: productionChangeTime,\n      })\n    )\n  }\n}\n\nfunction* transferFile(\n  api: PodloveApiClient,\n  production_uuid: string,\n  postId: Number,\n  fileData: any\n): any {\n  const response = yield api.post(`auphonic/transfer-single-file/${production_uuid}/${postId}`, {\n    file_data: fileData,\n  })\n\n  if (response.result) {\n    return response.result\n  } else {\n    return createErrorResponse(fileData, { message: getApiErrorMessage(response, 'Transfer failed') })\n  }\n}\n\nfunction* handleLoadPlusTransferStatus(\n  api: PodloveApiClient,\n  action: { type: string; payload: { production_uuid: string } }\n): any {\n  const { production_uuid } = action.payload\n\n  try {\n    const episodeId: string = yield select(selectors.episode.id)\n    if (!episodeId) {\n      console.error('Episode ID not available for loading transfer status')\n      return\n    }\n\n    const episodeData = yield api.get(`episodes/${episodeId}`)\n    const transferStatus = episodeData.result.auphonic_plus_transfer_status\n    const transferFiles = episodeData.result.auphonic_plus_transfer_files\n    const transferErrors = episodeData.result.auphonic_plus_transfer_errors\n\n    if (transferStatus) {\n      yield put(\n        auphonic.setPlusTransferStatus({\n          production_uuid,\n          status: transferStatus,\n          files: transferFiles,\n          errors: transferErrors,\n        })\n      )\n    }\n  } catch (error) {\n    console.error('Error loading PLUS transfer status:', error)\n  }\n}\n\nfunction* handlePlusTransferStatusChange(\n  api: PodloveApiClient,\n  action: { type: string; payload: { production_uuid: string; status: string } }\n): any {\n  const { status } = action.payload\n\n  if (status === 'completed') {\n    yield call(verifyAll, api)\n  }\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(auphonic.INIT, auphonicSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/chapters.sagas.ts",
    "content": "import { TakeableChannel } from '@redux-saga/core'\nimport { select, takeEvery, call, put, fork } from 'redux-saga/effects'\n\nimport keyboard from '@podlove/utils/keyboard'\nimport { selectors } from '@store'\nimport Timestamp from '@lib/timestamp'\nimport { PodloveApiClient } from '@lib/api'\nimport { notify } from '@store/notification.store'\nimport * as chapters from '@store/chapters.store'\nimport * as wordpress from '@store/wordpress.store'\nimport { parseAudacityChapters, parseMp4Chapters, parseHindeburgChapters, parsePodloveChapters } from '@lib/chapters'\n\nimport { PodloveChapter } from '../types/chapters.types'\nimport { channel, takeFirst } from './helper'\nimport { __ } from '../plugins/translations'\nimport { createApi } from './api'\n\nfunction* chaptersSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n\n  yield takeEvery([chapters.PARSE], handleImport)\n  yield takeEvery(chapters.DOWNLOAD, handleExport)\n  yield takeEvery(\n    [chapters.UPDATE, chapters.PARSED, chapters.ADD, chapters.REMOVE, chapters.SET_IMAGE],\n    save,\n    apiClient\n  )\n  const onKeyDown: TakeableChannel<any> = yield call(channel, keyboard.utils.keydown)\n\n  yield takeEvery(onKeyDown, handleKeydown)\n  yield takeEvery(chapters.SELECT_IMAGE, selectImageFromLibrary)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  if (!episodeId) {\n    return\n  }\n\n  const { result }: { result: { chapters: PodloveChapter[] } } = yield api.get(\n    `chapters/${episodeId}`\n  )\n\n  if (result) {\n    yield put(\n      chapters.set(\n        result.chapters.map((chapter) => ({\n          ...chapter,\n          start: Timestamp.fromString(chapter.start).totalMs,\n        }))\n      )\n    )\n  }\n}\n\nfunction* save(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const chapters: PodloveChapter[] = yield select(selectors.chapters.list)\n\n  yield api.put(`chapters/${episodeId}`, {\n    chapters: chapters.map((chapter) => ({\n      ...chapter,\n      start: new Timestamp(chapter.start).pretty,\n    })),\n  })\n}\n\n// Export handling\nfunction* handleExport(action: { type: string; payload: 'psc' | 'mp4' }) {\n  const chapters: PodloveChapter[] = yield select(selectors.chapters.list)\n\n  switch (action.payload) {\n    case 'psc':\n      download('chapters.psc', generatePscDownload(chapters))\n      break\n    case 'mp4':\n      download('chapters.txt', generateMp4Download(chapters))\n      break\n  }\n}\n\nfunction generatePscDownload(chapters: PodloveChapter[]): string {\n  const serializer = new XMLSerializer()\n\n  const psc = '<psc:chapters version=\"1.2\" xmlns:psc=\"http://podlove.org/simple-chapters\"/>'\n  const parser = new DOMParser()\n  const xmlDoc = parser.parseFromString(psc, 'text/xml')\n\n  // need both tries for Chrome/Firefox compatibility\n  let pscDoc: any = xmlDoc.getElementsByTagName('chapters')\n\n  if (!pscDoc.length) {\n    pscDoc = xmlDoc.getElementsByTagName('psc:chapters')\n  }\n\n  pscDoc = pscDoc[0]\n\n  chapters.forEach((chapter: PodloveChapter) => {\n    let node = xmlDoc.createElement('psc:chapter')\n    node.setAttribute('title', chapter.title || '')\n    node.setAttribute('start', chapter.start ? new Timestamp(chapter.start).pretty : '00:00:00')\n\n    if (chapter.href) {\n      node.setAttribute('href', chapter.href)\n    }\n\n    if (chapter.image) {\n      node.setAttribute('image', chapter.image)\n    }\n\n    pscDoc.appendChild(node)\n  })\n\n  let serialized = serializer.serializeToString(xmlDoc)\n\n  // poor man's formatting\n  let formatted = serialized\n    .replace(/\\<psc:chapter\\s/gi, '\\n    <psc:chapter ')\n    .replace('</psc:chapters>', '\\n</psc:chapters>')\n\n  return formatted\n}\n\nfunction generateMp4Download(chapters: PodloveChapter[]): string {\n  const timestamp = (chapter: PodloveChapter): string => {\n    if (isNaN(chapter.start)) {\n      return ''\n    }\n\n    return new Timestamp(chapter.start).pretty\n  }\n\n  const href = (chapter: PodloveChapter): string => {\n    return chapter.href ? '<' + chapter.href + '>' : ''\n  }\n\n  return (\n    chapters\n      .reduce((result: string[], chapter) => {\n        let line = timestamp(chapter) + ' ' + chapter.title + ' ' + href(chapter)\n\n        return [...result, line.trim()]\n      }, [])\n      .join('\\n') + '\\n'\n  )\n}\n\nfunction download(name: string, data: any) {\n  var blob = new Blob([data], { type: 'text/plain' })\n  const a = document.createElement('a')\n  a.href = window.URL.createObjectURL(blob)\n  a.download = name\n  document.body.appendChild(a)\n  a.click()\n  document.body.removeChild(a)\n}\n\n// Import handling\nfunction* handleImport(action: { type: string; payload: string }) {\n  const parser: ((text: string) => PodloveChapter[])[] = [\n    parseMp4Chapters,\n    parseAudacityChapters,\n    parseHindeburgChapters,\n    parsePodloveChapters,\n  ]\n\n  let parsedChapters: PodloveChapter[] | null = []\n\n  parser.forEach((parseFn) => {\n    if (parsedChapters !== null && parsedChapters.length > 0) {\n      return\n    }\n\n    try {\n      parsedChapters = parseFn(action.payload)\n    } catch (err) {\n      parsedChapters = null\n    }\n  })\n\n  if (parsedChapters === null) {\n    yield put(notify({ type: 'error', message: __('Unable to parse PSC chapters.', 'podlove-podcasting-plugin-for-wordpress') }))\n    return\n  }\n\n  yield put(chapters.parsed(parsedChapters))\n}\n\n// Key event handling\nfunction* handleKeydown(input: {\n  key: string\n  ctrl: boolean\n  shift: boolean\n  meta: boolean\n  alt: boolean\n}) {\n  const selectedIndex: number = yield select(selectors.chapters.selectedIndex)\n\n  if (selectedIndex === null) {\n    return\n  }\n\n  const chaptersList: PodloveChapter[] = yield select(selectors.chapters.list)\n\n  switch (true) {\n    case input.key === 'up':\n      if (selectedIndex === 0) {\n        yield put(chapters.select(chaptersList.length - 1))\n      } else {\n        yield put(chapters.select(selectedIndex - 1))\n      }\n      break\n    case input.key === 'down':\n      if (selectedIndex === chaptersList.length - 1) {\n        yield put(chapters.select(0))\n      } else {\n        yield put(chapters.select(selectedIndex + 1))\n      }\n      break\n    case input.key === 'esc':\n      yield put(chapters.select(null))\n      break\n  }\n}\n\nfunction* selectImageFromLibrary() {\n  yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: chapters.SET_IMAGE } }))\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(chapters.INIT, chaptersSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/contributors.sagas.ts",
    "content": "import { fork, put, select, takeEvery, throttle } from 'redux-saga/effects'\nimport { get, toInteger } from 'lodash'\n\nimport * as contributors from '@store/contributors.store'\nimport * as episode from '../store/episode.store'\n\nimport { PodloveApiClient } from '@lib/api'\n\nimport { takeFirst } from './helper'\nimport { createApi } from './api'\nimport { selectors } from '@store'\nimport { PodloveRole, PodloveGroup } from '../types/contributors.types'\nimport { PodloveEpisode, PodloveEpisodeContribution } from '../types/episode.types'\nimport { Action } from 'redux'\nimport { __ } from '../plugins/translations'\n\nfunction* contributorsSaga() {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(fetchContributors, apiClient)\n  yield fork(fetchRoles, apiClient)\n  yield fork(fetchGroups, apiClient)\n  yield fork(fetchEpisodeContributions, apiClient)\n  yield takeEvery(episode.CREATE_CONTRIBUTION, createEpisodeContribution, apiClient)\n  yield throttle(\n    3000,\n    [\n      episode.MOVE_CONTRIBUTION_DOWN,\n      episode.MOVE_CONTRIBUTION_UP,\n      episode.DELETE_CONTRIBUTION,\n      episode.UPDATE_CONTRIBUTION,\n      episode.ADD_CONTRIBUTION,\n    ],\n    updateEpisodeContributions,\n    apiClient\n  )\n}\n\nfunction* fetchEpisodeContributions(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  if (!episodeId) {\n    return\n  }\n\n  const { result }: { result: PodloveEpisode } = yield api.get(\n    `episodes/${episodeId}/contributions`\n  )\n\n  if (!result) {\n    return\n  }\n\n  yield put(episode.set({ contributions: get(result, ['contribution'], []) }))\n}\n\nfunction* updateEpisodeContributions(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  if (!episodeId) {\n    return\n  }\n\n  const roles: PodloveRole[] = yield select(selectors.contributors.roles)\n  const groups: PodloveGroup[] = yield select(selectors.contributors.groups)\n  const data: PodloveEpisodeContribution[] = yield select(selectors.episode.contributions)\n\n  let contributors = data.map(function ({ contributor_id, role_id, group_id, position, comment }) {\n    return {\n      contributor_id: toInteger(contributor_id),\n      role_id: toInteger(role_id) == 0 && roles.length > 0 ? roles[0].id : toInteger(role_id),\n      group_id: toInteger(group_id) == 0 && groups.length > 0 ? groups[0].id : toInteger(group_id),\n      position: toInteger(position),\n      comment: comment || ''\n    }\n  })\n\n  yield api.put(`episodes/${episodeId}/contributions`, { contributors })\n}\n\nfunction* fetchContributors(api: PodloveApiClient) {\n  const { result } = yield api.get('contributors', { query: { filter: 'all' } })\n\n  if (!result) {\n    return\n  }\n\n  yield put(contributors.setContributors(get(result, 'contributors', [])))\n}\n\nfunction* fetchRoles(api: PodloveApiClient) {\n  const { result } = yield api.get('contributors/roles')\n\n  if (!result) {\n    return\n  }\n\n  yield put(contributors.setRoles(get(result, 'roles', [])))\n}\n\nfunction* fetchGroups(api: PodloveApiClient) {\n  const { result } = yield api.get('contributors/groups')\n\n  if (!result) {\n    return\n  }\n\n  yield put(contributors.setGroups(get(result, 'groups', [])))\n}\n\nfunction* createEpisodeContribution(api: PodloveApiClient, action: Action) {\n  const { result: createContributorResult, error: createContributorError } = yield api.post(\n    `contributors`,\n    {}\n  )\n\n  if (createContributorError) {\n    return\n  }\n\n  const contributorId = createContributorResult?.id\n  const realname: string = get(action, ['payload'])\n  const { error: updateContributorError } = yield api.put(`contributors/${contributorId}`, { realname })\n\n  if (updateContributorError) {\n    return\n  }\n\n  const contributor = { id: contributorId, realname }\n\n  yield put(contributors.addContributor(contributor))\n  yield put(episode.addContribution(contributor))\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(contributors.INIT, contributorsSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/episode.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { get, isEmpty } from 'lodash'\nimport { Action } from 'redux'\nimport { debounce, fork, put, select, takeEvery } from 'redux-saga/effects'\nimport { PodloveEpisode } from '../types/episode.types'\nimport * as auphonic from '../store/auphonic.store'\nimport * as episode from '../store/episode.store'\nimport * as mediafiles from '../store/mediafiles.store'\nimport * as wordpress from '../store/wordpress.store'\nimport { createApi } from './api'\nimport { WebhookConfig } from './auphonic.sagas'\nimport { takeFirst } from './helper'\n\nlet EPISODE_UPDATE: { [key: string]: any } = {}\n\nfunction* episodeSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n\n  yield takeEvery(episode.UPDATE, collectEpisodeUpdate)\n  yield debounce(1000, episode.UPDATE, save, apiClient)\n  yield debounce(50, episode.QUICKSAVE, save, apiClient)\n  yield takeEvery(episode.SAVED, maybeMarkSlugAsChanged)\n  yield takeEvery(episode.SELECT_POSTER, selectImageFromLibrary)\n  yield takeEvery(episode.SET_POSTER, updatePoster)\n  yield takeEvery(wordpress.UPDATE, updatePosterFromGutenberg)\n  yield takeEvery(episode.SET, updateAuphonicWebhookConfig)\n}\n\nfunction* updateAuphonicWebhookConfig() {\n  const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig)\n  if (config) {\n    yield put(auphonic.updateWebhook(config.enabled))\n  }\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  if (!episodeId) {\n    return\n  }\n\n  const { result: episodesResult }: { result: PodloveEpisode } = yield api.get(\n    `episodes/${episodeId}`\n  )\n\n  if (episodesResult) {\n    if (episodesResult.slug === null) {\n      yield put(mediafiles.enableSlugAutogen())\n    }\n\n    yield put(episode.set(episodesResult))\n  }\n}\n\nfunction* collectEpisodeUpdate(action: Action) {\n  const prop = get(action, ['payload', 'prop'])\n  const value = get(action, ['payload', 'value'], null)\n\n  if (!prop) {\n    return\n  }\n\n  // If trying to update slug when frozen, block the update\n  if (prop === 'slug') {\n    const slugFrozen: boolean = yield select(selectors.episode.slugFrozen)\n    if (slugFrozen) {\n      console.warn('Attempted to update frozen slug - update blocked')\n      return\n    }\n  }\n\n  EPISODE_UPDATE[prop] = value\n}\n\nfunction* save(api: PodloveApiClient, action: Action) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  if (isEmpty(EPISODE_UPDATE)) {\n    return\n  }\n\n  yield api.put(`episodes/${episodeId}`, EPISODE_UPDATE, { query: { skip_validation: '1' } })\n  yield put(episode.saved(EPISODE_UPDATE))\n\n  EPISODE_UPDATE = {}\n}\n\nfunction* maybeMarkSlugAsChanged(action: { type: string; payload: object }) {\n  if (Object.keys(action.payload).includes('slug')) {\n    yield put(episode.slugChanged())\n  }\n}\n\nfunction* selectImageFromLibrary() {\n  yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: episode.SET_POSTER } }))\n}\n\nfunction* updatePoster(action: Action) {\n  yield put(episode.update({ prop: 'episode_poster', value: get(action, ['payload']) }))\n}\n\nfunction* updatePosterFromGutenberg(action: { type: string; payload: object }) {\n  const poster_setting: string = yield select(selectors.settings.imageAsset)\n\n  // only apply if the featured media is used for the episode cover\n  if (poster_setting != 'post-thumbnail') {\n    return\n  }\n\n  // only apply if the current event is about featured_media\n  if (get(action, ['payload', 'prop']) != 'featured_media') {\n    return\n  }\n\n  const img_url = get(action, ['payload', 'value', 'source_url'])\n  yield put(episode.update({ prop: 'poster', value: img_url }))\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(episode.INIT, episodeSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/helper.ts",
    "content": "import { eventChannel, Channel, channel as reduxChannel } from 'redux-saga'\nimport { fork, take, call, select, spawn, cancelled, put } from 'redux-saga/effects'\nimport { AxiosProgressEvent } from 'axios'\n\nexport const channel = (host: Function) =>\n  eventChannel((emitter) => {\n    const pipe = (args: any[]) => {\n      emitter(args || {})\n    }\n\n    host(pipe)\n\n    return () => {}\n  })\n\nexport function* takeFirst(pattern: string, saga: any, ...args: any[]) {\n  // @ts-ignore\n  const task = yield fork(function* () {\n    while (true) {\n      const action: { type: string; payload: any } = yield take(pattern)\n      yield call(saga, ...args.concat(action))\n    }\n  })\n\n  return task\n}\n\nexport function sleep(sec: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, sec * 1000))\n}\n\nexport function* waitFor(selector: any) {\n  const tester: boolean = yield select(selector)\n  if (tester) return // (1)\n\n  while (true) {\n    yield take('*')\n    const tester: boolean = yield select(selector)\n    if (tester) return // (1b)\n  }\n}\n\nexport type ProgressPayload = {\n  key: string\n  progress: number\n}\n\nexport interface ProgressData {\n  key: string\n  progress: number\n}\n\nexport function* watchProgressChannel(\n  progressChannel: Channel<ProgressData>,\n  progressAction: Function\n) {\n  try {\n    while (true) {\n      const payload: ProgressPayload = yield take(progressChannel)\n\n      // TODO: reset when selecting a file\n      // TODO: reset when using the source picker\n      if (progressAction.constructor.name === 'GeneratorFunction') {\n        yield call(function* () {\n          yield* progressAction(payload)\n        })\n      } else {\n        const action = progressAction(payload)\n        if (action) {\n          yield put(action)\n        }\n      }\n    }\n  } finally {\n    if ((yield cancelled()) as boolean) {\n      progressChannel.close()\n    }\n  }\n}\n\nexport function* createAndWatchProgressChannel(progressAction: Function) {\n  const progressChannel: Channel<ProgressData> = yield call(reduxChannel)\n  yield spawn(watchProgressChannel, progressChannel, progressAction)\n\n  return progressChannel\n}\n\nexport const createProgressHandler = (progressChannel: Channel<ProgressData>) => {\n  return (key: string) => (progressEvent: AxiosProgressEvent) => {\n    if (progressEvent.total) {\n      const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)\n      const payload: ProgressPayload = { key, progress: percentCompleted }\n\n      progressChannel.put(payload)\n    }\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/lifecycle.sagas.ts",
    "content": "import { eventChannel, END, EventChannel } from 'redux-saga'\nimport { call, takeEvery, put } from 'redux-saga/effects'\n\nimport * as lifecycle from '@store/lifecycle.store'\n\nfunction lifecycleSaga(): () => any {\n  return function* () {\n    const saveChannel: EventChannel<any> = yield call(clickListener, 'click', 'button.editor-post-publish-button')\n    yield takeEvery(saveChannel, save)\n    yield takeEvery(lifecycle.INIT, ready)\n  }\n}\n\nfunction* save() {\n  yield put(lifecycle.save())\n}\n\nfunction* ready() {\n  yield put(lifecycle.ready());\n}\n\nfunction clickListener(eventName: string, selector: string) {\n  return eventChannel(emitter => {\n    let target: HTMLElement\n\n    const eventListener = (event: MouseEvent) => {\n      emitter(event)\n    };\n\n    window.addEventListener('load', () => {\n      target = document.querySelector(selector) as HTMLElement;\n      target?.addEventListener(eventName, eventListener as EventListener);\n    })\n\n    return () => {\n      target?.removeEventListener(eventName, eventListener as EventListener)\n      emitter(END)\n    }\n  })\n}\n\nexport default lifecycleSaga\n"
  },
  {
    "path": "client/src/sagas/mediafiles.duration.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { put, select } from 'redux-saga/effects'\nimport * as episode from '@store/episode.store'\nimport { MediaFile } from '@store/mediafiles.store'\n\nexport function* maybeUpdateDuration(api: PodloveApiClient) {\n  const files: MediaFile[] = yield select(selectors.mediafiles.files)\n  const duration: string = yield select(selectors.episode.duration)\n  const enabledFiles = files.filter((file) => file.enable && file.size && file.url)\n  const audioFiles = enabledFiles.filter((file) => file.url.match(/\\.(mp3|mp4|m4a|ogg|oga|opus)$/))\n\n  let newDuration\n\n  if (audioFiles.length === 0) {\n    newDuration = '0'\n  } else {\n    const url = audioFiles[0].url\n    const result: number = yield fetchDuration(url)\n\n    newDuration = result.toString()\n  }\n\n  if (parseFloat(duration) !== parseFloat(newDuration)) {\n    yield put(episode.update({ prop: 'duration', value: newDuration }))\n  }\n}\n\nasync function loadMeta(audio: HTMLAudioElement) {\n  return new Promise<void>((resolve) => (audio.onloadedmetadata = () => resolve()))\n}\n\nasync function fetchDuration(src: string) {\n  var audio = new Audio()\n\n  audio.setAttribute('preload', 'metadata')\n  audio.setAttribute('src', src)\n  audio.load()\n\n  await loadMeta(audio)\n\n  return audio.duration\n}\n"
  },
  {
    "path": "client/src/sagas/mediafiles.enable.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { put, select } from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport { MediaFile } from '@store/mediafiles.store'\n\nexport function* handleEnable(api: PodloveApiClient, action: { type: string; payload: number }) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const asset_id = action.payload\n\n  const { result } = yield api.put(`episodes/${episodeId}/media/${asset_id}/enable`, {})\n\n  const fileUpdate: Partial<MediaFile> = {\n    asset_id: asset_id,\n    url: result.file_url,\n    size: result.file_size,\n    enable: true,\n  }\n\n  yield put(mediafiles.update(fileUpdate))\n\n  // Update episode freeze status if it was returned from enable\n  if (typeof result.slug_frozen !== 'undefined') {\n    yield put(episode.update({ prop: 'slug_frozen', value: result.slug_frozen }))\n  }\n}\n\nexport function* handleDisable(api: PodloveApiClient, action: { type: string; payload: number }) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const asset_id = action.payload\n\n  yield api.put(`episodes/${episodeId}/media/${asset_id}/disable`, {})\n}\n"
  },
  {
    "path": "client/src/sagas/mediafiles.fileselection.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { call, put, select, delay, fork } from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport * as progress from '@store/progress.store'\nimport { Action } from 'redux'\nimport { get } from 'lodash'\nimport { selectors } from '@store'\n\nexport function* handleFileSelection(api: PodloveApiClient, action: Action): Generator<any, void, any> {\n  const { files, episodeSlug } = get(action, ['payload'])\n\n  const existingSelectedFiles = yield select(selectors.mediafiles.selectedFiles)\n\n  const existingFileObjects = existingSelectedFiles.map((fileInfo: any) => fileInfo.file)\n  const newFiles = rejectExistingFiles(files, existingFileObjects)\n\n  if (newFiles.length > 0) {\n    const currentSlug = yield call(setEpisodeSlugIfNeeded, newFiles, episodeSlug)\n    const episodeId = yield select(selectors.episode.id)\n\n    // Immediately show files with original names (no file existence check yet)\n    const immediateFileInfos = newFiles.map(file => ({\n      file,\n      originalName: file.name,\n      newName: file.name,\n      fileExists: null, // Will be determined after filename generation\n    }))\n\n    const allFileInfos = [...existingSelectedFiles, ...immediateFileInfos]\n\n    // Show files immediately\n    yield put({\n      type: mediafiles.SET_FILE_INFO,\n      payload: allFileInfos,\n    })\n\n    // Generate filenames in the background for each new file\n    for (const file of newFiles) {\n      yield fork(generateFilenameForFile, api, file, episodeId)\n    }\n  }\n}\n\nfunction rejectExistingFiles(files: File[], existingFiles: File[]): File[] {\n  return files.filter((file: File) =>\n    !existingFiles.some((existing: File) =>\n      existing.name === file.name && existing.size === file.size\n    )\n  )\n}\n\nfunction extractSlugFromFilename(fileName: string): string {\n  return fileName.split('.').slice(0, -1).join('.')\n}\n\nfunction* setEpisodeSlugIfNeeded(files: File[], providedSlug: string | null): Generator<any, string, any> {\n  if (providedSlug) {\n    return providedSlug\n  }\n\n  if (files.length === 0) {\n    return ''\n  }\n\n  const firstFilename = files[0].name\n  const extractedSlug = extractSlugFromFilename(firstFilename)\n\n  yield put(episode.update({ prop: 'slug', value: extractedSlug }))\n\n  return extractedSlug\n}\n\nexport function* checkFileExists(api: PodloveApiClient, fileInfo: any): Generator<any, any, any> {\n  const { result: fileExists } = yield api.post(`plus/check_file_exists`, {\n    filename: fileInfo.file.name,\n  })\n\n  return {\n    ...fileInfo,\n    fileExists,\n  }\n}\n\n/**\n * Generate filename for a single file in the background and update the UI\n */\nexport function* generateFilenameForFile(api: PodloveApiClient, file: File, episodeId: string): Generator<any, void, any> {\n  const progressKey = `filename-generation-${file.name}`\n\n  try {\n    // Start loading state\n    yield put(progress.setProgressStatus({ key: progressKey, status: 'in_progress', message: 'Generating filename...' }))\n\n    const { result } = yield api.post('plus/generate_filename', {\n      original_filename: file.name,\n      episode_id: episodeId,\n    })\n\n    const newFileName = result.generated_filename\n    const newFile = new File([file], newFileName, {\n      type: file.type,\n      lastModified: file.lastModified,\n    })\n\n    // Check if file exists with the new filename\n    const fileInfo = {\n      file: newFile,\n      originalName: file.name,\n      newName: newFileName,\n    }\n\n    const fileInfoWithExistenceCheck = yield call(checkFileExists, api, fileInfo)\n\n    // Update the specific file in the selectedFiles array\n    yield call(updateFileInSelection, file.name, fileInfoWithExistenceCheck)\n\n    // Complete loading state\n    yield put(progress.setProgressStatus({ key: progressKey, status: 'finished', message: 'Filename generated' }))\n\n    // Clean up progress state after a short delay\n    yield fork(cleanupProgressState, progressKey, 2000)\n  } catch (error) {\n    // Error state\n    yield put(progress.setProgressStatus({ key: progressKey, status: 'error', message: 'Failed to generate filename' }))\n\n    // Clean up error state after a delay\n    yield fork(cleanupProgressState, progressKey, 5000)\n\n    console.warn('Failed to generate filename via API, keeping original:', error)\n  }\n}\n\n/**\n * Update a specific file in the selectedFiles array\n */\nexport function* updateFileInSelection(originalFileName: string, updatedFileInfo: any): Generator<any, void, any> {\n  const selectedFiles = yield select(selectors.mediafiles.selectedFiles)\n\n  const updatedSelectedFiles = selectedFiles.map((fileInfo: any) =>\n    fileInfo.originalName === originalFileName ? updatedFileInfo : fileInfo\n  )\n\n  yield put({\n    type: mediafiles.SET_FILE_INFO,\n    payload: updatedSelectedFiles,\n  })\n}\n\nexport function* cleanupProgressState(progressKey: string, delayMs: number): Generator<any, void, any> {\n  yield delay(delayMs)\n  yield put(progress.resetProgress(progressKey))\n}\n"
  },
  {
    "path": "client/src/sagas/mediafiles.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport {\n  fork,\n  put,\n  select,\n  takeEvery,\n  takeLatest,\n  debounce,\n  throttle,\n} from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport * as wordpress from '@store/wordpress.store'\nimport { MediaFile } from '@store/mediafiles.store'\nimport { takeFirst } from './helper'\nimport { createApi } from './api'\nimport { get } from 'lodash'\n\n// Import handlers from other saga modules\nimport {\n  handleEnable,\n  handleDisable\n} from './mediafiles.enable.sagas'\nimport {\n  handleVerify,\n  verifyAll\n} from './mediafiles.verification.sagas'\nimport {\n  maybeUpdateDuration\n} from './mediafiles.duration.sagas'\nimport {\n  maybeUpdateSlug,\n  updateSelectedFileNames,\n  handleUnfreezeSlug\n} from './mediafiles.slug.sagas'\nimport {\n  handleFileSelection\n} from './mediafiles.fileselection.sagas'\nimport {\n  selectMediaFromLibrary,\n  triggerPlusUpload,\n  setUploadMedia\n} from './mediafiles.upload.sagas'\n\nfunction* mediafilesSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  let files: MediaFile[] = []\n\n  if (episodeId) {\n    const { result } = yield api.get(`episodes/${episodeId}/media`)\n    files = get(result, ['results'], [])\n  }\n\n  yield put(mediafiles.set(files))\n\n  yield takeEvery(mediafiles.ENABLE, handleEnable, api)\n  yield takeEvery(mediafiles.DISABLE, handleDisable, api)\n  yield takeEvery(mediafiles.VERIFY, handleVerify, api)\n  yield takeEvery(mediafiles.VERIFY_ALL, verifyAll, api)\n  yield takeLatest(episode.SLUG_CHANGED, verifyAll, api)\n  yield takeLatest(episode.SLUG_CHANGED, updateSelectedFileNames, api)\n  yield debounce(2000, wordpress.UPDATE, maybeUpdateSlug, api)\n  yield takeEvery(mediafiles.FILE_SELECTED, handleFileSelection, api)\n\n  yield throttle(\n    2000,\n    [mediafiles.ENABLE, mediafiles.DISABLE, mediafiles.UPDATE],\n    maybeUpdateDuration,\n    api\n  )\n  yield takeEvery(mediafiles.UPLOAD_INTENT, selectMediaFromLibrary)\n  yield takeEvery(mediafiles.PLUS_UPLOAD_INTENT, triggerPlusUpload, api)\n  yield takeEvery(mediafiles.SET_UPLOAD_URL, setUploadMedia, api)\n  yield takeEvery(mediafiles.UNFREEZE_SLUG, handleUnfreezeSlug, api)\n\n  yield put(mediafiles.initDone())\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(mediafiles.INIT, mediafilesSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/mediafiles.slug.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { put, select, fork } from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport { generateFilenameForFile } from './mediafiles.fileselection.sagas'\n\nexport function* maybeUpdateSlug(\n  api: PodloveApiClient,\n  action: { type: string; payload: { prop: string; value: any } }\n) {\n  const episodeId: boolean = yield select(selectors.episode.id)\n  const oldSlug: boolean = yield select(selectors.episode.slug)\n  const enabled: boolean = yield select(selectors.mediafiles.slugAutogenerationEnabled)\n\n  if (enabled && action.payload.prop == 'title' && action.payload.value) {\n    const newTitle = action.payload.value\n\n    const { result } = yield api.get(`episodes/${episodeId}/build_slug`, {\n      query: { title: newTitle },\n    })\n    if (oldSlug != result.slug) {\n      yield put(episode.update({ prop: 'slug', value: result.slug }))\n    }\n  }\n}\n\nexport function* updateSelectedFileNames(api: PodloveApiClient): Generator<any, void, any> {\n  const selectedFiles: any[] = yield select(selectors.mediafiles.selectedFiles)\n  const newSlug: string = yield select(selectors.episode.slug)\n\n  if (selectedFiles.length > 0 && newSlug) {\n    // Recreate file infos with original names first\n    const originalFiles = selectedFiles.map(fileInfo => {\n      return new File([fileInfo.file], fileInfo.originalName, {\n        type: fileInfo.file.type,\n        lastModified: fileInfo.file.lastModified,\n      })\n    })\n\n    // Immediately update files with original names (no file existence check yet)\n    const immediateFileInfos = originalFiles.map(file => ({\n      file,\n      originalName: file.name,\n      newName: file.name,\n      fileExists: null, // Will be determined after filename generation\n    }))\n\n    // Show files immediately\n    yield put({\n      type: mediafiles.SET_FILE_INFO,\n      payload: immediateFileInfos,\n    })\n\n    // Generate new filenames in the background\n    const episodeId = yield select(selectors.episode.id)\n    for (const file of originalFiles) {\n      yield fork(generateFilenameForFile, api, file, episodeId)\n    }\n  }\n}\n\nexport function* handleUnfreezeSlug(api: PodloveApiClient): Generator<any, void, any> {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  if (!episodeId) {\n    return\n  }\n\n  try {\n    const { result } = yield api.post(`episodes/${episodeId}/unfreeze_slug`, {})\n\n    yield put(episode.set({ slug_frozen: result.slug_frozen }))\n  } catch (error) {\n    console.error('Failed to unfreeze slug:', error)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/mediafiles.upload.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { call, put, select } from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport * as wordpress from '@store/wordpress.store'\nimport * as progress from '@store/progress.store'\nimport {\n  createAndWatchProgressChannel,\n  createProgressHandler,\n  ProgressPayload,\n} from './helper'\nimport { Action } from 'redux'\nimport { get } from 'lodash'\nimport axios, { AxiosResponse } from 'axios'\nimport { Channel } from 'redux-saga'\n\nexport function* selectMediaFromLibrary() {\n  yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: mediafiles.SET_UPLOAD_URL } }))\n}\n\n/**\n * Uploads a file to Podlove Plus service\n *\n * This saga:\n * 1. Requests a pre-signed upload URL from the Plus API\n * 2. Uploads the file directly to the provided URL\n * 3. Extracts the permanent file URL and dispatches it via setUploadUrl action\n * 4. Tracks upload progress\n */\nexport function* triggerPlusUpload(api: PodloveApiClient, action: Action): Generator<any, void, any> {\n  const file = get(action, ['payload'])\n  const progressKey = `plus-upload-${file.name}`\n\n  // Reset any previous progress for this file\n  yield put(progress.resetProgress(progressKey))\n\n  try {\n    const uploadUrl = yield call(getUploadUrl, api, file.name)\n    const fileUrl = yield call(uploadFileToUrl, uploadUrl, file, progressKey)\n    yield put(mediafiles.setUploadUrl(fileUrl))\n    const completeResult = yield call(completeFileUpload, api, file.name)\n\n    console.log('completeResult', completeResult)\n  } catch (error) {\n    console.error('File upload failed:', error)\n    yield put(\n      progress.setProgressStatus({\n        key: progressKey,\n        status: 'error',\n        message: error instanceof Error ? error.message : 'File upload failed',\n      })\n    )\n  }\n}\n\n/**\n * Gets a pre-signed upload URL from the Plus API\n */\nfunction* getUploadUrl(api: PodloveApiClient, filename: string): Generator<any, string, any> {\n  const { result: upload_url } = yield api.post(`plus/create_file_upload`, {\n    filename,\n  })\n\n  if (!upload_url) {\n    throw new Error('Failed to get upload URL')\n  }\n\n  return upload_url\n}\n\n/**\n * Uploads file to the provided URL with progress tracking\n */\nfunction* uploadFileToUrl(uploadUrl: string, file: File, progressKey: string): Generator<any, string, any> {\n  const progressChannel: Channel<ProgressPayload> = yield call(\n    createAndWatchProgressChannel,\n    handleProgressUpdate\n  )\n\n  const handleProgress = createProgressHandler(progressChannel)\n\n  const response: AxiosResponse<any> = yield call(axios.put, uploadUrl, file, {\n    headers: { 'Content-Type': file.type },\n    onUploadProgress: handleProgress(progressKey),\n  })\n\n  const fileUrl = response.config.url?.split('?')[0]\n\n  if (!fileUrl) {\n    throw new Error('Failed to extract file URL from response')\n  }\n\n  return fileUrl\n}\n\n/**\n * Completes the file upload process via Plus API\n */\nfunction* completeFileUpload(api: PodloveApiClient, filename: string): Generator<any, any, any> {\n  const { result: completeResult } = yield api.post(`plus/complete_file_upload`, {\n    filename,\n  })\n\n  if (!completeResult) {\n    throw new Error('Failed to complete file upload')\n  }\n\n  return completeResult\n}\n\nfunction* handleProgressUpdate(value: ProgressPayload) {\n  yield put(progress.setProgress(value))\n\n  yield put(\n    progress.setProgressStatus({\n      key: value.key,\n      status: value.progress == 100 ? 'finished' : 'in_progress',\n    })\n  )\n}\n\nexport function* setUploadMedia(api: PodloveApiClient, action: Action) {\n  const url = get(action, ['payload'])\n  const slug = url.split('\\\\').pop().split('/').pop().split('.').shift()\n  const currentSlug: string = yield select(selectors.episode.slug)\n\n  if (!currentSlug) {\n    yield put(episode.update({ prop: 'slug', value: slug }))\n    yield put(episode.quicksave())\n  } else {\n    // If slug is already set, verify the media files, which is otherwise a side\n    // effect of saving the episode\n    yield call(verifyAll, api)\n  }\n}\n\n// Import verifyAll from verification saga\nimport { verifyAll } from './mediafiles.verification.sagas'\n"
  },
  {
    "path": "client/src/sagas/mediafiles.verification.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { selectors } from '@store'\nimport { all, fork, put, select } from 'redux-saga/effects'\nimport * as mediafiles from '@store/mediafiles.store'\nimport * as episode from '@store/episode.store'\nimport { MediaFile } from '@store/mediafiles.store'\n\nexport function* verifyAll(api: PodloveApiClient) {\n  const episodeId: number = yield select(selectors.episode.id)\n  const mediaFiles: MediaFile[] = yield select(selectors.mediafiles.files)\n\n  // verify all\n  yield all(mediaFiles.map((file) => fork(verifyEpisodeAsset, api, episodeId, file.asset_id)))\n}\n\nfunction* verifyEpisodeAsset(api: PodloveApiClient, episodeId: number, assetId: number) {\n  const mediaFiles: MediaFile[] = yield select(selectors.mediafiles.files)\n  const prevMediaFile: MediaFile | undefined = mediaFiles.find((mf) => mf.asset_id == assetId)\n\n  yield put(\n    mediafiles.update({\n      asset_id: assetId,\n      is_verifying: true,\n    })\n  )\n\n  const { result } = yield api.put(`episodes/${episodeId}/media/${assetId}/verify`, {})\n\n  // auto-enable if file size changed from zero to non-zero\n  const enable = (!prevMediaFile?.size && result.file_size) || prevMediaFile?.enable\n\n  const fileUpdate: Partial<MediaFile> = {\n    asset_id: assetId,\n    url: result.file_url,\n    size: result.file_size,\n    enable: enable,\n    is_verifying: false,\n  }\n\n  yield put(mediafiles.update(fileUpdate))\n\n  // Update episode freeze status if it was returned from verification\n  if (typeof result.slug_frozen !== 'undefined') {\n    yield put(episode.update({ prop: 'slug_frozen', value: result.slug_frozen }))\n  }\n}\n\nexport function* handleVerify(api: PodloveApiClient, action: { type: string; payload: number }) {\n  const episodeId: number = yield select(selectors.episode.id)\n  const assetId = action.payload\n\n  yield verifyEpisodeAsset(api, episodeId, assetId)\n}\n"
  },
  {
    "path": "client/src/sagas/notification.saga.ts",
    "content": "import { takeEvery } from 'redux-saga/effects'\n\nimport { get } from 'lodash'\nimport { NOTIFY } from '@store/notification.store'\n\nfunction errorSaga() {\n  return function* () {\n    yield takeEvery(NOTIFY, showNotification)\n  }\n}\n\nfunction* showNotification(action: { type: string, payload: { type: 'success' | 'info' | 'error' | 'warning', message: string } }) {\n  const dispatch = get(globalThis, ['wp', 'data', 'dispatch'])\n\n  if (dispatch) {\n    wordPressError(dispatch, get(action, ['payload']))\n  } else {\n    consoleError(action.payload)\n  }\n}\n\nfunction wordPressError(\n  dispatch: Function,\n  { type, message }: { type: 'success' | 'warning' | 'error' | 'info'; message: string }\n) {\n  if (!message) {\n    return\n  }\n\n  dispatch('core/notices').createNotice(\n    type, // Can be one of: success, info, warning, error.\n    message, // Text string to display.\n    {\n      type: 'snackbar',\n      isDismissible: true, // Whether the user can dismiss the notice.\n    }\n  )\n}\n\nfunction consoleError({\n  type,\n  message,\n}: {\n  type: 'success' | 'warning' | 'error' | 'info'\n  message: string\n}) {\n  switch (type) {\n    case 'success':\n    case 'info':\n      console.log(message)\n      break\n    case 'warning':\n      console.warn(message)\n      break\n    case 'error':\n      console.error(message)\n      break\n  }\n}\n\nexport default errorSaga\n"
  },
  {
    "path": "client/src/sagas/plus.sagas.ts",
    "content": "import * as plus from '@store/plus.store'\nimport { takeFirst } from './helper'\nimport { fork, put, select, call, takeEvery } from 'redux-saga/effects'\nimport { PodloveApiClient } from '@lib/api'\nimport { createApi } from './api'\n\nfunction* plusSaga() {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const { result } = yield api.get(`admin/plus/features`)\n\n  yield put(plus.setFeature({ feature: 'fileStorage', value: result.file_storage }))\n  yield put(plus.setFeature({ feature: 'feedProxy', value: result.feed_proxy }))\n\n  yield takeEvery(plus.SET_FEATURE, setFeature, api)\n  yield takeEvery(plus.GET_TOKEN, getToken, api)\n  yield takeEvery(plus.SAVE_TOKEN, saveToken, api)\n\n  yield put(plus.getToken())\n}\n\nfunction* setFeature(api: PodloveApiClient, action: ReturnType<typeof plus.setFeature>) {\n  const { feature, value } = action.payload\n  yield api.post(`admin/plus/set_feature`, { feature, value })\n}\n\nfunction* getToken(api: PodloveApiClient) {\n  try {\n    yield put(plus.setLoading(true))\n    const { result } = yield api.get(`admin/plus/token`)\n    yield put(plus.setToken(result.token || ''))\n\n    if (result.token) {\n      yield call(validateToken, api, result.token)\n    }\n  } catch (error) {\n    console.error('Failed to get token:', error)\n    yield put(plus.setToken(''))\n    yield put(plus.setUser(null))\n  } finally {\n    yield put(plus.setLoading(false))\n  }\n}\n\nfunction* validateToken(api: PodloveApiClient, token: string) {\n  try {\n    const { result } = yield api.get(`admin/plus/validate_token`)\n    if (result.user) {\n      yield put(plus.setUser(result.user))\n    } else {\n      yield put(plus.setUser(null))\n    }\n  } catch (error) {\n    console.error('Failed to validate token:', error)\n    yield put(plus.setUser(null))\n  }\n}\n\nfunction* saveToken(api: PodloveApiClient, action: ReturnType<typeof plus.saveToken>) {\n  try {\n    yield put(plus.setSaving(true))\n    const token = action.payload\n    yield api.post(`admin/plus/save_token`, { token })\n\n    if (token) {\n      yield call(validateToken, api, token)\n    } else {\n      yield put(plus.setUser(null))\n    }\n  } catch (error) {\n    console.error('Failed to save token:', error)\n  } finally {\n    yield put(plus.setSaving(false))\n  }\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(plus.INIT, plusSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/plusFileMigration.sagas.ts",
    "content": "import { takeFirst } from '../sagas/helper'\nimport { fork, put, select, call } from 'redux-saga/effects'\nimport { PodloveApiClient } from '@lib/api'\nimport { createApi } from '../sagas/api'\n\nimport * as plusFileMigration from '@store/plusFileMigration.store'\nimport * as auphonic from '@store/auphonic.store'\nimport { selectors } from '@store'\nimport { determineMigrationStatus } from '@lib/statusHelpers'\n\nfunction* plusFileMigrationSaga() {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient): Generator<any, void, any> {\n  const { result: migrationStatusResult } = yield api.get(`plus/get_migration_status`)\n  yield put(\n    plusFileMigration.setMigrationComplete({\n      isMigrationComplete: migrationStatusResult.is_complete,\n    })\n  )\n\n  const { result } = yield api.get(`admin/plus/episodes_for_migration`)\n\n  const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = result.episodes.map(\n    (episode: any) => {\n      return {\n        episodeName: episode.episode_title,\n        files: episode.files.map((file: any) => {\n          return {\n            name: file.filename,\n            localUrl: file.local_url,\n            remoteUrl: file.plus_url,\n            state: 'init',\n          }\n        }),\n      }\n    }\n  )\n\n  yield put(plusFileMigration.setEpisodesWithFiles({ episodesWithFiles }))\n  yield put(plusFileMigration.setTotalState({ totalState: 'ready' }))\n\n  yield takeFirst(plusFileMigration.START_MIGRATION, startMigration, api)\n}\n\nfunction* migrateFile(\n  api: PodloveApiClient,\n  episodeIndex: number,\n  fileIndex: number\n): Generator<any, void, any> {\n  const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = yield select(\n    selectors.plusFileMigration.episodesWithFiles\n  )\n\n  const currentEpisode = episodesWithFiles[episodeIndex]\n  const currentFile = currentEpisode.files[fileIndex]\n  const currentEpisodeName = currentEpisode.episodeName\n  const currentFileName = currentFile.name\n\n  yield put(\n    plusFileMigration.setCurrentMetadata({\n      currentEpisodeName: currentEpisodeName,\n      currentFileName: currentFileName,\n    })\n  )\n\n  yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'in_progress' }))\n\n  try {\n    const response = yield api.post(`plus/migrate_file`, {\n      filename: currentFileName,\n      file_url: currentFile.localUrl,\n    })\n\n    if (response.result === false) {\n      yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'error' }))\n    } else {\n      yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'finished' }))\n\n      // Set auphonic transfer status to completed for UI consistency\n      yield put(auphonic.setPlusTransferStatus({\n        production_uuid: 'migration',\n        status: 'completed'\n      }))\n    }\n\n  } catch (error) {\n    yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'error' }))\n    throw error\n  }\n}\n\nfunction* startMigration(api: PodloveApiClient): Generator<any, void, any> {\n  yield put(plusFileMigration.setTotalState({ totalState: 'in_progress' }))\n\n  const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = yield select(\n    selectors.plusFileMigration.episodesWithFiles\n  )\n\n  const totalFiles = episodesWithFiles.reduce((acc, episode) => acc + episode.files.length, 0)\n  let migratedFiles = 0\n  let hasErrors = false\n\n  const allMigrationTasks = episodesWithFiles.flatMap((episode, episodeIndex) =>\n    episode.files.map((file, fileIndex) => ({ episodeIndex, fileIndex }))\n  )\n\n  for (const task of allMigrationTasks) {\n    try {\n      yield call(migrateFile, api, task.episodeIndex, task.fileIndex)\n      migratedFiles++\n    } catch (error) {\n      hasErrors = true\n      console.error('Error migrating file:', error)\n    } finally {\n      const progress = Math.round((migratedFiles / totalFiles) * 100)\n      yield put(plusFileMigration.setProgress({ progress }))\n    }\n  }\n\n  yield put(\n    plusFileMigration.setTotalState({\n      totalState: determineMigrationStatus(hasErrors),\n    })\n  )\n\n  if (!hasErrors) {\n    yield api.post('plus/set_migration_complete', {})\n  }\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(plusFileMigration.INIT, plusFileMigrationSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/podcast.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { fork, put, takeEvery } from 'redux-saga/effects'\nimport { takeFirst } from './helper'\nimport * as lifecycle from '../store/lifecycle.store'\nimport * as podcast from '../store/podcast.store'\nimport { createApi } from './api'\nimport { Action } from 'redux'\nimport { get, isEmpty } from 'lodash'\n\ninterface PodcastData {\n  title: string | null\n  subtitle: string | null\n  summary: string | null\n  mnemonic: string | null\n  itunes_type: string | null\n  author_name: string | null\n  poster: string | null\n  link: string | null\n  license_name: string | null\n  license_url: string | null\n}\n\nlet PODCAST_UPDATE: { [key: string]: any } = {}\n\nfunction* podcastSaga() {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n\n  yield takeEvery(podcast.UPDATE, collectPodcastUpdate)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const { result }: { result: PodcastData } = yield api.get(`podcast`)\n\n  if (result) {\n    yield put(podcast.set(result))\n  }\n}\n\nfunction collectPodcastUpdate(action: Action) {\n  const prop = get(action, ['payload', 'prop'])\n  const value = get(action, ['payload', 'value'], null)\n\n  if (!prop) {\n    return\n  }\n\n  PODCAST_UPDATE[prop] = value\n}\n\nfunction* save(api: PodloveApiClient, action: Action) {\n  if (isEmpty(PODCAST_UPDATE)) {\n    return\n  }\n\n  yield api.put(`podcast/`, PODCAST_UPDATE)\n  yield put(podcast.saved(PODCAST_UPDATE))\n\n  PODCAST_UPDATE = {}\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(lifecycle.INIT, podcastSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/relatedEpisodes.sagas.ts",
    "content": "import { createApi } from './api'\nimport { PodloveApiClient } from '@lib/api'\nimport { fork, takeEvery } from '@redux-saga/core/effects'\nimport { select, put } from 'redux-saga/effects'\nimport { get } from 'lodash'\nimport { selectors } from '@store'\nimport { PodloveEpisodeList } from '../types/relatedEpisodes.types'\nimport { takeFirst } from './helper'\nimport * as relatedEpisodesStore from '@store/relatedEpisodes.store'\n\ntype EpisodeApiListItem = {\n  id: number\n  title: string\n}\n\nfunction* relatedEpisodesSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n\n  yield fork(initialize, apiClient)\n  yield takeEvery(relatedEpisodesStore.SET_SELECTED_EPISODES, save, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  const [relatedEpisodes, episodeList ]: [\n    { result: Number[] },\n    { result: EpisodeApiListItem[] }\n  ] = yield Promise.all([\n    api.get(`episodes/${episodeId}/related?status=all`),\n    api.get('episodes?status=all&sort_by=post_id&order_by=asc')\n  ])\n\n  const related = get(relatedEpisodes, ['result', 'relatedEpisodes'], [])\n  const episodes = get(episodeList, ['result', 'results'], []).map((episode: EpisodeApiListItem) => ({\n    episode_id: episode.id,\n    episode_title: episode.title,\n  }))\n\n  const arr = related.map( (r : any) => (r.related_episode_id))\n\n  yield put(relatedEpisodesStore.setSelectedEpisodes(arr))\n  yield put(relatedEpisodesStore.setEpisodeList(episodes))\n}\n\nfunction* save(\n  api: PodloveApiClient, \n  action: {type: string}\n) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const selectEpisodes: Number[] = yield select(selectors.relatedEpisodes.selectEpisode)\n\n  yield api.post(`episodes/${episodeId}/related`, {related: selectEpisodes})\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(relatedEpisodesStore.INIT, relatedEpisodesSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/shows.sagas.ts",
    "content": "import { PodloveApiClient } from '@lib/api'\nimport { fork, put, select, takeEvery } from 'redux-saga/effects'\nimport { takeFirst } from './helper'\nimport { __ } from '../plugins/translations'\nimport { createApi } from './api'\n\nimport { selectors } from '@store'\nimport * as shows from '@store/shows.store'\nimport * as episode from '@store/episode.store'\nimport * as auphonic from '@store/auphonic.store'\nimport { PodloveShow } from '../types/shows.types'\nimport { get } from 'lodash'\n\nfunction* showsSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n  yield fork(initialize, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const modules: string[] = yield select(selectors.settings.modules)\n  const { result: showsList }: { result: PodloveShow[] } = yield api.get(`shows`)\n\n  if (shows) {\n    yield put(shows.set(showsList))\n    yield takeEvery(episode.UPDATE, maybeUpdateEpisodeNumber)\n\n    if (modules.includes('automatic_numbering')) {\n      yield takeEvery(shows.SELECT, updateEpisodeNumber, api)\n    }\n\n    if (modules.includes('auphonic')) {\n      yield takeEvery(shows.SELECT, setAuphonicPreset, showsList)\n    }\n  }\n}\n\nfunction* setAuphonicPreset(shows: PodloveShow[], action: { type: string; payload: string }) {\n  const show = shows.find((show) => show.slug === action.payload)\n  if (show && show.auphonic_preset) {\n    yield put(auphonic.setPreset(show.auphonic_preset))\n  }\n}\n\nfunction* maybeUpdateEpisodeNumber(action: {\n  type: string\n  payload: { prop: string; value: any }\n}) {\n  const prop = get(action, ['payload', 'prop'])\n  const value = get(action, ['payload', 'value'], null)\n\n  if (prop === 'show') {\n    yield put(shows.select(value))\n  }\n}\n\nfunction* updateEpisodeNumber(api: PodloveApiClient, action: { type: string; payload: string }) {\n  const { result: number }: { result: number } = yield api.get(`shows/next_episode_number`, {\n    query: { show: action.payload },\n  })\n\n  yield put(episode.update({ prop: 'number', value: number.toString() }))\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(shows.INIT, showsSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/sagas/transcripts.sagas.ts",
    "content": "import { fork } from '@redux-saga/core/effects'\nimport { takeEvery, select, put } from 'redux-saga/effects'\nimport { get } from 'lodash'\nimport { selectors } from '@store'\nimport { PodloveTranscript, PodloveTranscriptVoice } from '../types/transcripts.types'\nimport * as transcriptsStore from '@store/transcripts.store'\nimport { createApi } from './api'\nimport { PodloveApiClient } from '@lib/api'\nimport { takeFirst } from './helper'\n\nfunction* transcriptsSaga(): any {\n  const apiClient: PodloveApiClient = yield createApi()\n\n  yield fork(initialize, apiClient)\n  yield takeEvery(transcriptsStore.IMPORT_TRANSCRIPTS, importTranscripts, apiClient)\n  yield takeEvery(transcriptsStore.UPDATE_VOICE, updateVoice, apiClient)\n  yield takeEvery(transcriptsStore.DELETE_TRANSCRIPTS, deleteTranscripts, apiClient)\n  yield takeEvery(transcriptsStore.IMPORT_ASSET_TRANSCRIPTS, importTranscriptFromAsset, apiClient)\n}\n\nfunction* initialize(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  const [transcripts, voices]: [\n    { result: PodloveTranscript[] },\n    { result: PodloveTranscriptVoice[] }\n  ] = yield Promise.all([\n    api.get(`transcripts/${episodeId}`),\n    api.get(`transcripts/voices/${episodeId}`),\n  ])\n\n  yield put(transcriptsStore.setTranscripts(get(transcripts, ['result', 'transcript'], [])))\n  yield put(transcriptsStore.setVoices(get(voices, ['result', 'voices'], [])))\n}\n\nfunction* importTranscripts(\n  api: PodloveApiClient,\n  action: { type: string, payload: string }\n) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const { result } = yield api.put(`transcripts/${episodeId}`, { content: action.payload })\n\n  if (result) {\n    yield fork(initialize, api)\n  }\n}\n\nfunction* importTranscriptFromAsset(\n  api: PodloveApiClient,\n  action: { type: string }\n) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const { result } = yield api.put(`transcripts/${episodeId}`, { asset: 1})\n\n  if (result) {\n    yield fork(initialize, api)\n  }\n}\n\nfunction* updateVoice(api: PodloveApiClient, action: { type: string, payload: { voice: string; contributor: string } }) {\n  const episodeId: string = yield select(selectors.episode.id)\n\n  yield api.post(`transcripts/voices/${episodeId}`, {\n    voice: action.payload.voice,\n    contributor_id: action.payload.contributor,\n  })\n}\n\nfunction* deleteTranscripts(api: PodloveApiClient) {\n  const episodeId: string = yield select(selectors.episode.id)\n  const { result } = yield api.delete(`transcripts/${episodeId}`)\n\n  if (result) {\n    yield fork(initialize, api)\n  }\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(transcriptsStore.INIT, transcriptsSaga)\n  }\n}\n\n"
  },
  {
    "path": "client/src/sagas/wordpress.sagas.ts",
    "content": "import { call, put, select, takeEvery } from 'redux-saga/effects'\n\nimport * as lifecycleStore from '@store/lifecycle.store'\nimport * as wordpressStore from '@store/wordpress.store'\nimport * as episodeStore from '@store/episode.store'\nimport selectors from '@store/selectors'\n\nimport { takeFirst, channel } from './helper'\n\nimport * as wordpress from '../lib/wordpress'\nimport { get } from 'lodash'\nimport { Action } from 'redux'\n\nfunction* wordpressSaga(): any {\n  const generateTitle: boolean = yield select(selectors.settings.autoGenerateEpisodeTitle)\n\n  if (typeof wordpress.store?.subscribe !== 'undefined') {\n    yield takeEvery(yield call(channel, wordpress.store?.subscribe), wordpressGutenbergUpdate)\n  }\n\n  if (wordpress.postTitleInput) {\n    yield takeEvery(yield call(channel, wordpress.postTitleListener), postTitleUpdate)\n  }\n\n  if (generateTitle) {\n    yield takeEvery(episodeStore.SET, updatePostTitle)\n    yield takeEvery(episodeStore.UPDATE, updatePostTitle)\n  }\n\n  if (wordpress.media) {\n    yield takeEvery(wordpressStore.SELECT_MEDIA_FROM_LIBRARY as any, selectMediaFromLibrary)\n  }\n}\n\nfunction getFeaturedImageIdFromEditor() {\n  if (!wordpress.store?.select) {\n    return null\n  }\n\n  const editor = wordpress.store.select('core/editor')\n  if (!editor?.getEditedPostAttribute) {\n    return null\n  }\n\n  return editor.getEditedPostAttribute('featured_media')\n}\n\nfunction getTitleFromEditor() {\n  if (!wordpress.store?.select) {\n    return ''\n  }\n\n  const editor = wordpress.store.select('core/editor')\n  if (!editor?.getEditedPostAttribute) {\n    return ''\n  }\n\n  return editor.getEditedPostAttribute('title')\n}\n\nfunction* wordpressGutenbergUpdate() {\n  const title: string = getTitleFromEditor()\n  const imgId: number = getFeaturedImageIdFromEditor()\n  const media = imgId ? wordpress.store.select('core').getMedia(imgId) : null\n\n  const oldTitle: string | null = yield select(selectors.post.title)\n  const oldMedia: object | null = yield select(selectors.post.featuredMedia)\n\n  if (oldTitle != title) {\n    yield put(\n      wordpressStore.update({\n        prop: 'title',\n        value: title,\n      })\n    )\n  }\n\n  if (get(oldMedia, ['id']) != get(media, ['id'])) {\n    yield put(\n      wordpressStore.update({\n        prop: 'featured_media',\n        value: media,\n      })\n    )\n  }\n}\n\nfunction* postTitleUpdate(title: String) {\n  yield put(\n    wordpressStore.update({\n      prop: 'title',\n      value: title,\n    })\n  )\n}\n\nfunction* updatePostTitle() {\n  if (!wordpress.postTitleInput) {\n    return\n  }\n\n  const template: string = yield select(selectors.settings.blogTitleTemplate)\n\n  if (!template) {\n    return\n  }\n\n  const title: string = yield select(selectors.episode.title)\n  const episodeNumber: string = yield select(selectors.episode.number)\n  const mnemonic: string = yield select(selectors.podcast.mnemonic)\n  // TODO: get from episode?\n  const seasonNumber: string = ''\n  const padding: number = yield select(selectors.settings.episodeNumberPadding)\n\n  const newTitle = template\n    .replace('%mnemonic%', mnemonic || '')\n    .replace('%episode_number%', (episodeNumber || '').padStart(padding || 0, '0'))\n    .replace('%season_number%', seasonNumber || '')\n    .replace('%episode_title%', title || '')\n\n  if (wordpress.postTitleInput.value != newTitle) {\n    wordpress.postTitleInput.value = newTitle\n\n    yield postTitleUpdate(newTitle)\n  }\n}\n\nfunction* selectMediaFromLibrary(action: { payload: { onSuccess: Action } }) {\n  const successAction = get(action, ['payload', 'onSuccess'])\n\n  if (!successAction) {\n    console.warn('Missing successAction')\n    return\n  }\n\n  const mediaLibrary = wordpress.media({\n    title: 'Select or Upload Media Of Your Chosen Persuasion',\n    button: {\n      text: 'Use this media',\n    },\n    multiple: false, // Set to true to allow multiple files to be selected\n  })\n\n  const mediaSelectionDialogue: Promise<string> = new Promise((resolve) => {\n    mediaLibrary.on('select', () => {\n      const { url } = mediaLibrary.state().get('selection').first().toJSON()\n      resolve(url)\n    })\n  })\n\n  mediaLibrary.open()\n\n  try {\n    const url: string = yield mediaSelectionDialogue\n    yield put({\n      ...successAction,\n      payload: url,\n    })\n  } finally {\n  }\n}\n\nexport default function () {\n  return function* () {\n    yield takeFirst(lifecycleStore.INIT, wordpressSaga)\n  }\n}\n"
  },
  {
    "path": "client/src/store/admin.store.ts",
    "content": "import { get } from 'lodash'\nimport { handleActions, createAction } from 'redux-actions'\n\nexport const INIT = 'podlove/publisher/admin/INIT'\nexport const SET = 'podlove/publisher/admin/SET'\nexport const UPDATE_TYPE = 'podlove/publisher/admin/UPDATE_TYPE'\n\nexport type State = {\n    bannerHide: boolean | null,\n    type: string | null,\n    feedUrl: string | null\n}\n\nexport const initialState: State = {\n    bannerHide: null,\n    type: null,\n    feedUrl: null\n}\n\nexport const init = createAction<void>(INIT)\nexport const set = createAction<Partial<State>>(SET)\nexport const update_type = createAction<string>(UPDATE_TYPE)\n\nexport const reducer = handleActions(\n    {\n        [SET]: (state: State, action: { payload: Partial<State> }): State => ({\n            bannerHide: get(action, ['payload', 'banner_hide'], state.bannerHide),\n            type: get(action, ['payload', 'type'], state.type),\n            feedUrl: get(action, ['payload', 'feedUrl'], state.feedUrl),\n        }),\n        [UPDATE_TYPE]: (state: State, action: { payload: string }): State => ({\n            ...state,\n            type: action.payload\n        }),\n    },\n    initialState\n)\n\nexport const selectors = {\n    bannerHide: (state: State) => state.bannerHide,\n    type: (state: State) => state.type,\n    feedUrl: (state: State) => state.feedUrl,\n}\n"
  },
  {
    "path": "client/src/store/auphonic.store.ts",
    "content": "import { createAction, handleActions } from 'redux-actions'\n\nexport type Service = {\n  uuid: string\n  display_name: string\n  email: string\n  incoming: boolean\n  outgoing: boolean\n  type: string\n}\n\nexport type Metadata = {\n  album: string\n  append_chapters: boolean\n  artist: string\n  genre: string\n  license: string\n  license_url: string\n  publisher: string\n  subtitle: string\n  summary: string\n  tags: string[]\n  title: string\n  track: string\n  url: string\n  year: string\n}\n\nexport type AuphonicChapter = {\n  start: string\n  start_sec: number\n  start_output: string\n  start_output_sec: number\n  title: string\n  image?: string\n  url?: string\n}\n\nexport type Production = {\n  uuid: string\n  status: number\n  status_string: string\n  error_message: string\n  error_status: any | null\n  warning_message: string\n  warning_status: any | null\n  edit_page: string\n  status_page: string\n  waveform_image: string\n  image: string | null\n  metadata: Metadata\n  creation_time: string\n  change_time?: string | null\n  is_multitrack: boolean\n  multi_input_files: AuphonicInputFile[]\n  input_file: string\n  chapters: AuphonicChapter[]\n  output_basename: string\n  output_files?: AuphonicOutputFile[]\n  outgoing_services: object[]\n  algorithms: object\n  speech_recognition: object\n  service: string | null\n}\n\nexport type PlusTransferFile = {\n  success: boolean | null // null indicates pending/processing state\n  status: 'pending' | 'processing' | 'completed' | 'failed' // explicit status for UI\n  filename: string\n  download_url: string\n  message: string\n}\n\nexport type AuphonicInputFile = {\n  id: string\n  input_file: string\n  input_filetype: string\n  input_length: number\n  service: string | null\n  type: 'multitrack' | string\n  offset: number\n  input_channels: number\n  input_bitrate: number\n  input_samplerate: number\n  algorithms: AuphonicTrackAlgorithms\n}\n\nexport type AuphonicOutputFile = {\n  format: string\n  bitrate: string\n  suffix: string\n  ending: string\n  filename: string\n  mono_mixdown: boolean\n  split_on_chapters: boolean\n  outgoing_services: string[]\n}\n\nexport type AuphonicTrackAlgorithms = {\n  backforeground: string\n  denoise: boolean\n  denoiseamount: number\n  filtering: boolean\n}\n\nexport type Preset = Production & {\n  preset_name: string\n}\n\nexport type AudioTrack = {\n  identifier: string\n  identifier_new: string\n  fileSelection: any\n  input_file_name: string\n  filtering: boolean\n  noise_and_hum_reduction: boolean\n  fore_background: string\n  track_gain: string\n  save_state: 'new' | 'unchanged' | 'edited' | 'deleted'\n}\n\nexport type FileSelection = {\n  urlValue: string | null\n  fileValue: string | null\n  currentServiceSelection: string | null\n  fileSelection: string | null\n}\n\nexport type PlusTransferStatus = {\n  production_uuid: string\n  status: 'waiting_for_webhook' | 'in_progress' | 'completed' | 'completed_with_errors' | 'failed'\n  files?: PlusTransferFile[]\n  errors?: string\n}\n\nexport type State = {\n  token: string | null\n  production: Production | null\n  productions: Production[] | null\n  presets: Preset[] | null\n  preset: string | null\n  services: Service[]\n  service_files: object\n  tracks: AudioTrack[]\n  file_selections: object\n  current_file_selection: string | null\n  is_saving: boolean\n  is_initializing: boolean\n  publish_when_done: boolean\n  plus_transfer_status: PlusTransferStatus | null\n}\n\nexport const initialState: State = {\n  token: null,\n  production: null,\n  productions: [],\n  presets: [],\n  preset: null,\n  services: [],\n  service_files: {},\n  tracks: [],\n  file_selections: {},\n  current_file_selection: null,\n  is_saving: false,\n  is_initializing: true,\n  publish_when_done: false,\n  plus_transfer_status: null,\n}\n\nexport const INIT = 'podlove/publisher/auphonic/INIT'\nexport const INIT_DONE = 'podlove/publisher/auphonic/INIT_DONE'\nexport const SET_TOKEN = 'podlove/publisher/auphonic/SET_TOKEN'\nexport const SET_PRODUCTION = 'podlove/publisher/auphonic/SET_PRODUCTION'\nexport const SET_PRODUCTIONS = 'podlove/publisher/auphonic/SET_PRODUCTIONS'\nexport const SET_SERVICES = 'podlove/publisher/auphonic/SET_SERVICES'\nexport const CREATE_PRODUCTION = 'podlove/publisher/auphonic/CREATE_PRODUCTION'\nexport const CREATE_MULTITRACK_PRODUCTION =\n  'podlove/publisher/auphonic/CREATE_MULTITRACK_PRODUCTION'\nexport const SAVE_PRODUCTION = 'podlove/publisher/auphonic/SAVE_PRODUCTION'\nexport const START_PRODUCTION = 'podlove/publisher/auphonic/START_PRODUCTION'\nexport const DESELECT_PRODUCTION = 'podlove/publisher/auphonic/DESELECT_PRODUCTION'\nexport const SELECT_SERVICE = 'podlove/publisher/auphonic/SELECT_SERVICE'\nexport const SET_SERVICE_FILES = 'podlove/publisher/auphonic/SET_SERVICE_FILES'\nexport const SELECT_TRACKS = 'podlove/publisher/auphonic/SELECT_TRACKS'\nexport const ADD_TRACK = 'podlove/publisher/auphonic/ADD_TRACK'\nexport const REMOVE_TRACK = 'podlove/publisher/auphonic/REMOVE_TRACK'\nexport const UPDATE_TRACK = 'podlove/publisher/auphonic/UPDATE_TRACK'\nexport const SET_PRESETS = 'podlove/publisher/auphonic/SET_PRESETS'\nexport const SET_PRESET = 'podlove/publisher/auphonic/SET_PRESET'\nexport const UPDATE_FILE_SELECTION = 'podlove/publisher/auphonic/UPDATE_FILE_SELECTION'\nexport const START_POLLING = 'podlove/publisher/auphonic/START_POLLING'\nexport const STOP_POLLING = 'podlove/publisher/auphonic/STOP_POLLING'\nexport const START_SAVING = 'podlove/publisher/auphonic/START_SAVING'\nexport const STOP_SAVING = 'podlove/publisher/auphonic/STOP_SAVING'\nexport const UPDATE_WEBHOOK = 'podlove/publisher/auphonic/UPDATE_WEBHOOK'\nexport const SET_PLUS_TRANSFER_STATUS = 'podlove/publisher/auphonic/SET_PLUS_TRANSFER_STATUS'\nexport const TRIGGER_PLUS_TRANSFER = 'podlove/publisher/auphonic/TRIGGER_PLUS_TRANSFER'\nexport const LOAD_PLUS_TRANSFER_STATUS = 'podlove/publisher/auphonic/LOAD_PLUS_TRANSFER_STATUS'\n\nexport const init = createAction<void>(INIT)\nexport const initDone = createAction<void>(INIT_DONE)\nexport const setToken = createAction<string>(SET_TOKEN)\n\n// Productions\nexport const setProduction = createAction<Production>(SET_PRODUCTION)\nexport const deselectProduction = createAction<void>(DESELECT_PRODUCTION)\nexport const setProductions = createAction<Production[]>(SET_PRODUCTIONS)\nexport const createProduction = createAction<void>(CREATE_PRODUCTION)\nexport const createMultitrackProduction = createAction<void>(CREATE_MULTITRACK_PRODUCTION)\nexport const saveProduction = createAction<Partial<Production>>(SAVE_PRODUCTION)\nexport const startProduction = createAction<Partial<Production>>(START_PRODUCTION)\n\n// Presets\nexport const setPresets = createAction<Preset[]>(SET_PRESETS)\nexport const setPreset = createAction<string>(SET_PRESET)\n\n// Files & File Services\nexport const setServices = createAction<Service[]>(SET_SERVICES)\nexport const setServiceFiles =\n  createAction<{ uuid: string; files: string[] | null }>(SET_SERVICE_FILES)\nexport const selectService = createAction<string>(SELECT_SERVICE)\nexport const updateFileSelection =\n  createAction<{ key: string; prop: string; value: string | File | null }>(UPDATE_FILE_SELECTION)\n\n// Tracks\nexport const selectTracks = createAction<string>(SELECT_TRACKS)\nexport const addTrack = createAction<void>(ADD_TRACK)\nexport const removeTrack = createAction<string>(REMOVE_TRACK)\nexport const updateTrack = createAction<{ track: Partial<AudioTrack>; index: number }>(UPDATE_TRACK)\n\n// Polling\nexport const startPolling = createAction<void>(START_POLLING)\nexport const stopPolling = createAction<void>(STOP_POLLING)\n\n// Saving State\nexport const startSaving = createAction<void>(START_SAVING)\nexport const stopSaving = createAction<void>(STOP_SAVING)\n\n// Webhook\nexport const updateWebhook = createAction<boolean>(UPDATE_WEBHOOK)\nexport const setPlusTransferStatus = createAction<PlusTransferStatus>(SET_PLUS_TRANSFER_STATUS)\nexport const triggerPlusTransfer = createAction<{ production_uuid: string }>(TRIGGER_PLUS_TRANSFER)\nexport const loadPlusTransferStatus = createAction<{ production_uuid: string }>(LOAD_PLUS_TRANSFER_STATUS)\n\nexport const reducer = handleActions(\n  {\n    [INIT_DONE]: (state: State): State => ({\n      ...state,\n      is_initializing: false,\n    }),\n    [UPDATE_FILE_SELECTION]: (\n      state: State,\n      action: { type: string; payload: { key: string; prop: string; value: string | null } }\n    ): State => {\n      // FIXME: mark track as modified when selection changes\n      return {\n        ...state,\n        current_file_selection: action.payload.key,\n        file_selections: {\n          ...state.file_selections,\n          [action.payload.key]: {\n            //@ts-ignore\n            ...state.file_selections[action.payload.key],\n            [action.payload.prop]: action.payload.value,\n          },\n        },\n      }\n    },\n    [ADD_TRACK]: (state: State, action: any): State => {\n      const id = `Track ${state.tracks.length + 1}`\n\n      return {\n        ...state,\n        tracks: [\n          ...state.tracks,\n          {\n            identifier: id,\n            identifier_new: id,\n            fileSelection: null,\n            input_file_name: '',\n            filtering: true,\n            noise_and_hum_reduction: false,\n            fore_background: 'auto',\n            track_gain: '0',\n            save_state: 'new',\n          },\n        ],\n      }\n    },\n    [REMOVE_TRACK]: (state: State, action: { type: string; payload: string }): State => {\n      return {\n        ...state,\n        tracks: state.tracks.filter((track, index) => track.identifier != action.payload),\n      }\n    },\n    [UPDATE_TRACK]: (\n      state: State,\n      action: { type: string; payload: { track: Partial<AudioTrack>; index: number } }\n    ): State => {\n      // save_state: 'new' | 'unchanged' | 'edited' | 'deleted'\n      const track_save_state = (\n        track: Partial<AudioTrack>,\n        track_payload: Partial<AudioTrack>\n      ): Partial<AudioTrack> => {\n        const old_state = track.save_state\n\n        if (old_state == 'new') {\n          return { save_state: 'new' }\n        }\n\n        return { save_state: 'edited' }\n      }\n\n      const tracks = state.tracks.reduce(\n        (result: AudioTrack[], track, trackIndex) => [\n          ...result,\n          trackIndex === action.payload.index\n            ? {\n                ...track,\n                ...action.payload.track,\n                ...track_save_state(track, action.payload.track),\n              }\n            : track,\n        ],\n        []\n      )\n\n      return { ...state, tracks }\n    },\n    [SET_SERVICE_FILES]: (\n      state: State,\n      action: { payload: { uuid: string; files: string[] | null } }\n    ): State => {\n      const { uuid, files } = action.payload\n\n      return {\n        ...state,\n        service_files: { ...state.service_files, [uuid]: files },\n      }\n    },\n    [SET_SERVICES]: (state: State, action: { payload: Service[] }): State => ({\n      ...state,\n      services: action.payload,\n    }),\n    [SET_PRESETS]: (state: State, action: { payload: Preset[] | null }): State => ({\n      ...state,\n      presets: action.payload,\n    }),\n    [SET_PRODUCTIONS]: (state: State, action: { payload: Production[] | null }): State => ({\n      ...state,\n      productions: action.payload,\n    }),\n    [SET_PRODUCTION]: (state: State, action: { payload: Production | null }): State => {\n      const production = action.payload\n\n      const file_selections = () => {\n        if (production?.is_multitrack) {\n          return (\n            production?.multi_input_files?.reduce((acc, file, index) => {\n              let service = file.service\n\n              if (!service) {\n                if (file.input_file.substring(0, 4) == 'http') {\n                  service = 'url'\n                } else {\n                  service = 'file'\n                }\n              }\n\n              return {\n                ...acc,\n                [`${production?.uuid}_t${index}`]: {\n                  currentServiceSelection: service,\n                  fileSelection: file.service ? file.input_file : null,\n                  urlValue: service == 'url' ? file.input_file : null,\n                  fileValue: null,\n                } as FileSelection,\n              }\n            }, {}) || {}\n          )\n        } else {\n          // single track\n\n          let service = production?.service\n          const input_file = production?.input_file\n\n          if (!service) {\n            if (input_file?.substring(0, 4) == 'http') {\n              service = 'url'\n            } else {\n              service = 'file'\n            }\n          }\n\n          return {\n            [`${production?.uuid}`]: {\n              currentServiceSelection: service,\n              fileSelection: service ? input_file : null,\n              urlValue: service == 'url' ? input_file : null,\n              fileValue: null,\n            } as FileSelection,\n          }\n        }\n      }\n\n      return {\n        ...state,\n        production: production,\n        file_selections: file_selections(),\n        tracks:\n          action.payload?.multi_input_files?.reduce((acc, file) => {\n            return [\n              ...acc,\n              {\n                identifier: file.id,\n                identifier_new: file.id,\n                filtering: file.algorithms?.filtering,\n                noise_and_hum_reduction: file.algorithms?.denoise,\n                fore_background: file.algorithms?.backforeground,\n                input_file_name: file.input_file,\n                save_state: 'unchanged',\n              } as AudioTrack,\n            ]\n          }, [] as AudioTrack[]) || [],\n      }\n    },\n    [DESELECT_PRODUCTION]: (state: State): State => ({\n      ...state,\n      production: null,\n      tracks: [],\n      file_selections: [],\n      current_file_selection: null,\n    }),\n    [SET_PRESET]: (state: State, action: { payload: string | null }): State => ({\n      ...state,\n      preset: action.payload,\n    }),\n    [SET_TOKEN]: (state: State, action: { payload: string | null }): State => ({\n      ...state,\n      token: action.payload,\n    }),\n    [START_SAVING]: (state: State, action: { payload: null }): State => ({\n      ...state,\n      is_saving: true,\n    }),\n    [STOP_SAVING]: (state: State, action: { payload: null }): State => ({\n      ...state,\n      is_saving: false,\n    }),\n    [UPDATE_WEBHOOK]: (state: State, action: { payload: boolean }): State => ({\n      ...state,\n      publish_when_done: action.payload,\n    }),\n    [SET_PLUS_TRANSFER_STATUS]: (state: State, action: { payload: PlusTransferStatus }): State => {\n      return {\n        ...state,\n        plus_transfer_status: action.payload,\n      }\n    },\n  },\n  initialState\n)\n\nconst chaptersPayload = (chapters: AuphonicChapter[] | undefined) => {\n  if (!chapters) {\n    return []\n  }\n\n  return chapters.map((chapter) => {\n    let payload: {\n      start: string\n      title: string\n      image?: string\n      url?: string\n    } = {\n      start: chapter.start,\n      title: chapter.title,\n    }\n\n    if (chapter.image) {\n      payload.image = chapter.image\n    }\n\n    if (chapter.url) {\n      payload.url = chapter.url\n    }\n\n    return payload\n  })\n}\n\nconst outputFilesPayload = (output_files: AuphonicOutputFile[] | undefined) => {\n  if (!output_files) {\n    return []\n  }\n\n  return output_files.map((file) => {\n    return {\n      format: file.format,\n      bitrate: file.bitrate,\n      suffix: file.suffix,\n      ending: file.ending,\n      filename: file.filename,\n      mono_mixdown: file.mono_mixdown,\n      split_on_chapters: file.split_on_chapters,\n      outgoing_services: file.outgoing_services,\n    }\n  })\n}\n\nconst productionPayload = (state: State) => {\n  const production = state.production\n\n  return {\n    uuid: production?.uuid,\n    metadata: production?.metadata,\n    input_file: production?.input_file,\n    chapters: chaptersPayload(production?.chapters),\n    output_files: outputFilesPayload(production?.output_files),\n    output_basename: production?.output_basename,\n    outgoing_services: production?.outgoing_services,\n    algorithms: production?.algorithms,\n    speech_recognition: production?.speech_recognition,\n  }\n}\n\nexport const selectors = {\n  token: (state: State) => state.token,\n  production: (state: State) => state.production,\n  productionId: (state: State) => state.production?.uuid,\n  productions: (state: State) => state.productions,\n  presets: (state: State) => state.presets,\n  preset: (state: State) => state.preset,\n  productionPayload,\n  services: (state: State) => state.services,\n  incomingServices: (state: State) => state.services.filter((s: Service) => s.incoming),\n  outgoingServices: (state: State) => state.services.filter((s: Service) => s.outgoing),\n  serviceFiles: (state: State) => state.service_files,\n  tracks: (state: State) => state.tracks,\n  fileSelections: (state: State) => state.file_selections,\n  currentFileSelection: (state: State) => state.current_file_selection,\n  isSaving: (state: State) => state.is_saving,\n  isInitializing: (state: State) => state.is_initializing,\n  publishWhenDone: (state: State) => state.publish_when_done,\n  plusTransferStatus: (state: State) => state.plus_transfer_status?.status,\n  plusTransferFiles: (state: State) => state.plus_transfer_status?.files,\n  plusTransferErrors: (state: State) => state.plus_transfer_status?.errors,\n}\n"
  },
  {
    "path": "client/src/store/chapters.store.ts",
    "content": "import { get } from 'lodash'\nimport { handleActions, createAction } from 'redux-actions'\nimport { PodloveChapter } from '../types/chapters.types'\n\nexport type State = {\n  chapters: PodloveChapter[]\n  selected: number | null\n}\n\nexport const initialState: State = {\n  chapters: [],\n  selected: null,\n}\n\nexport const INIT = 'podlove/publisher/chapter/INIT'\nexport const UPDATE = 'podlove/publisher/chapter/UPDATE'\nexport const SELECT = 'podlove/publisher/chapter/SELECT'\nexport const REMOVE = 'podlove/publisher/chapter/REMOVE'\nexport const ADD = 'podlove/publisher/chapter/ADD'\nexport const PARSE = 'podlove/publisher/chapter/PARSE'\nexport const PARSED = 'podlove/publisher/chapter/PARSED'\nexport const SET = 'podlove/publisher/chapter/SET'\nexport const DOWNLOAD = 'podlove/publisher/chapter/DOWNLOAD'\nexport const SELECT_IMAGE = 'podlove/publisher/chapter/SELECT_IMAGE'\nexport const SET_IMAGE = 'podlove/publisher/chapter/SET_IMAGE'\n\nexport const init = createAction<void>(INIT)\nexport const update = createAction<{ chapter: Partial<PodloveChapter>; index: number }>(UPDATE)\nexport const select = createAction<number | null>(SELECT)\nexport const remove = createAction<number>(REMOVE)\nexport const add = createAction<void>(ADD)\nexport const parse = createAction<string>(PARSE)\nexport const parsed = createAction<PodloveChapter[]>(PARSED)\nexport const set = createAction<PodloveChapter[]>(SET)\nexport const download = createAction<'psc' | 'mp4'>(DOWNLOAD)\nexport const selectImage = createAction<void>(SELECT_IMAGE)\nexport const setImage = createAction<string>(SET_IMAGE)\n\nexport const reducer = handleActions(\n  {\n    [PARSED]: (state: State, action: typeof parsed): State => ({\n      ...state,\n      selected: null,\n      chapters: get(action, ['payload'], []) as PodloveChapter[],\n    }),\n    [SET]: (state: State, action: typeof set): State => ({\n      ...state,\n      selected: null,\n      chapters: get(action, ['payload'], []) as PodloveChapter[],\n    }),\n    [UPDATE]: (\n      state: State,\n      action: { type: string; payload: { chapter: Partial<PodloveChapter>; index: number } }\n    ): State => {\n      let selectedChapterIndex = selectedIndex(state)\n      const selectedChapter = selected(state)\n\n      // update chapter\n      const chapters = state.chapters.reduce(\n        (result: PodloveChapter[], chapter, chapterIndex) => [\n          ...result,\n          chapterIndex === action.payload.index\n            ? { ...chapter, ...action.payload.chapter }\n            : chapter,\n        ],\n        []\n      )\n\n      // sort chapters by time\n      const sortedChapters = chapters.sort((a, b) => a.start - b.start)\n\n      // update selected index\n      const newSelectedIndex = sortedChapters.findIndex(\n        (chapter) => selectedChapter && chapter.title == selectedChapter.title\n      )\n\n      return {\n        ...state,\n        chapters: sortedChapters,\n        selected: newSelectedIndex >= 0 ? newSelectedIndex : selectedChapterIndex,\n      }\n    },\n    [SELECT]: (state: State, action: { type: string; payload: number }): State => ({\n      ...state,\n      selected: action.payload,\n    }),\n    [ADD]: (state: State): State => ({\n      ...state,\n      chapters: [\n        ...state.chapters,\n        {\n          start: get(state, ['chapters', state.chapters.length - 1, 'start'], 0),\n          title: '',\n          href: '',\n          image: '',\n        },\n      ],\n    }),\n    [REMOVE]: (state: State, action: { type: string; payload: number }): State => ({\n      ...state,\n      selected: null,\n      chapters: state.chapters.filter((chapter, index) => index !== action.payload),\n    }),\n    [SET_IMAGE]: (state: State, action: { type: string; payload: string }): State => ({\n      ...state,\n      chapters: state.chapters.map((chapter, index) => {\n        if (index !== state.selected) {\n          return chapter\n        }\n\n        return {\n          ...chapter,\n          image: action.payload\n        }\n      })\n    })\n  },\n  initialState\n)\n\nconst chapters = (state: State) => state.chapters\n\nconst selectedIndex = (state: State) => state.selected\n\nconst selected = (state: State) =>\n  state.selected !== null ? get(state, ['chapters', state.selected], null) : null\n\nexport const selectors = {\n  chapters,\n  selectedIndex,\n  selected,\n}\n"
  },
  {
    "path": "client/src/store/contributors.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\nimport { PodloveContributor, PodloveGroup, PodloveRole } from '../types/contributors.types';\n\nexport type State = {\n  contributors: PodloveContributor[],\n  roles: PodloveRole[],\n  groups: PodloveGroup[]\n}\n\nexport const initialState: State = {\n  contributors: [],\n  roles: [],\n  groups: [],\n};\n\nexport const INIT = 'podlove/publisher/contributors/INIT'\nexport const SET_CONTRIBUTORS = 'podlove/publisher/contributors/SET_CONTRIBUTORS'\nexport const SET_ROLES = 'podlove/publisher/contributors/SET_ROLES'\nexport const SET_GROUPS = 'podlove/publisher/contributors/SET_GROUPS'\nexport const ADD_CONTRIBUTOR = 'podlove/publisher/contributors/ADD'\n\nexport const init = createAction<void>(INIT);\nexport const setContributors = createAction<PodloveContributor[]>(SET_CONTRIBUTORS);\nexport const setRoles = createAction<PodloveRole[]>(SET_ROLES);\nexport const setGroups = createAction<PodloveGroup[]>(SET_GROUPS);\nexport const addContributor = createAction<Partial<PodloveContributor>>(ADD_CONTRIBUTOR);\n\nexport const reducer = handleActions({\n  [SET_CONTRIBUTORS]: (state: State, action: { payload: PodloveContributor[] }): State => ({\n    ...state,\n    contributors: action.payload\n  }),\n  [SET_ROLES]: (state: State, action: { payload: PodloveRole[] }): State => ({\n    ...state,\n    roles: action.payload\n  }),\n  [SET_GROUPS]: (state: State, action: { payload: PodloveGroup[] }): State => ({\n    ...state,\n    groups: action.payload\n  }),\n  [ADD_CONTRIBUTOR]: (state: State, action: { payload: PodloveContributor }): State => ({\n    ...state,\n    contributors: [\n      ...state.contributors,\n      action.payload\n    ]\n  })\n}, initialState);\n\nexport const selectors = {\n  contributors: (state: State) => state.contributors,\n  roles: (state: State) => state.roles,\n  groups: (state: State) => state.groups,\n}\n"
  },
  {
    "path": "client/src/store/episode.store.ts",
    "content": "import { get, pick } from 'lodash'\nimport { handleActions } from 'redux-actions'\nimport { createAction } from 'redux-actions'\nimport Timestamp from '@lib/timestamp'\nimport * as lifecycle from './lifecycle.store'\nimport { PodloveEpisodeContribution } from '../types/episode.types'\nimport { PodloveContributor } from '../types/contributors.types'\nimport { arrayMove } from '@lib/array'\n\nexport const INIT = 'podlove/publisher/episode/INIT'\nexport const UPDATE = 'podlove/publisher/episode/UPDATE'\nexport const QUICKSAVE = 'podlove/publisher/episode/QUICKSAVE'\nexport const SAVED = 'podlove/publisher/episode/SAVED'\nexport const SLUG_CHANGED = 'podlove/publisher/episode/SLUG_CHANGED'\nexport const SET = 'podlove/publisher/episode/SET'\nexport const SET_POSTER = 'podlove/publisher/episode/SET_POSTER'\nexport const SELECT_POSTER = 'podlove/publisher/episode/SELECT_POSTER'\nexport const MOVE_CONTRIBUTION_UP = 'podlove/publisher/episode/MOVE_CONTRIBUTION_UP'\nexport const MOVE_CONTRIBUTION_DOWN = 'podlove/publisher/episode/MOVE_CONTRIBUTION_DOWN'\nexport const DELETE_CONTRIBUTION = 'podlove/publisher/episode/DELETE_CONTRIBUTION'\nexport const UPDATE_CONTRIBUTION = 'podlove/publisher/episode/UPDATE_CONTRIBUTION'\nexport const ADD_CONTRIBUTION = 'podlove/publisher/episode/ADD_CONTRIBUTION'\nexport const CREATE_CONTRIBUTION = 'podlove/publisher/episode/CREATE_CONTRIBUTION'\n\nexport type State = {\n  id: string | null\n  slug: string | null\n  slug_frozen: boolean\n  duration: number | null\n  number: string | null\n  title: string | null\n  subtitle: string | null\n  summary: string | null\n  type: 'full' | 'trailer' | 'bonus' | null\n  episode_poster: string | null\n  poster: string | null\n  mnemonic: string | null\n  explicit: boolean | null\n  auphonic_production_id: 'string' | null\n  is_auphonic_production_running: boolean\n  auphonic_webhook_config: object | null\n  auphonic_plus_transfer_change_time: string | null\n  soundbite_start: number | null\n  soundbite_duration: number | null\n  soundbite_title: string | null\n  license_name: string | null\n  license_url: string | null\n  contributions: PodloveEpisodeContribution[]\n  show: string | null\n}\n\nexport const initialState: State = {\n  id: null,\n  slug: null,\n  slug_frozen: false,\n  duration: null,\n  number: null,\n  subtitle: null,\n  title: null,\n  summary: null,\n  type: null,\n  episode_poster: null,\n  poster: null,\n  mnemonic: null,\n  explicit: null,\n  auphonic_production_id: null,\n  is_auphonic_production_running: false,\n  auphonic_webhook_config: null,\n  auphonic_plus_transfer_change_time: null,\n  soundbite_start: null,\n  soundbite_duration: null,\n  soundbite_title: null,\n  contributions: [],\n  license_name: null,\n  license_url: null,\n  show: null,\n}\n\nexport const update = createAction<{ prop: string; value: any }>(UPDATE)\nexport const quicksave = createAction<void>(QUICKSAVE)\nexport const init = createAction<void>(INIT)\nexport const selectPoster = createAction<void>(SELECT_POSTER)\nexport const set = createAction<{\n  slug?: string\n  slug_frozen?: boolean\n  number?: string\n  duration?: string\n  title?: string\n  subtitle?: string\n  summary?: string\n  episode_poster?: string\n  poster?: string\n  mnemonic?: string\n  explicit?: boolean\n  contributions?: object\n  auphonic_production_id?: string\n  is_auphonic_production_running?: boolean\n  auphonic_webhook_config?: object\n  auphonic_plus_transfer_change_time?: string\n  soundbite_start?: string\n  soundbite_duration?: string\n  soundbite_title?: string\n  license_name?: string\n  license_url?: string\n  show?: string\n}>(SET)\nexport const moveContributionUp = createAction<PodloveEpisodeContribution>(MOVE_CONTRIBUTION_UP)\nexport const moveContributionDown = createAction<PodloveEpisodeContribution>(MOVE_CONTRIBUTION_DOWN)\nexport const deleteContribution = createAction<PodloveEpisodeContribution>(DELETE_CONTRIBUTION)\nexport const updateContribution = createAction<PodloveEpisodeContribution>(UPDATE_CONTRIBUTION)\nexport const addContribution = createAction<Partial<PodloveContributor>>(ADD_CONTRIBUTION)\nexport const createContribution = createAction<string>(CREATE_CONTRIBUTION)\nexport const saved = createAction<object>(SAVED)\nexport const slugChanged = createAction<void>(SLUG_CHANGED)\n\nexport const reducer = handleActions(\n  {\n    [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({\n      ...state,\n      id: get(action, ['payload', 'episode', 'id'], null),\n      duration: Timestamp.fromString(get(action, ['payload', 'episode', 'duration'], null)).totalMs,\n    }),\n    [UPDATE]: (state: State, action: typeof update): State => {\n      const prop = get(action, ['payload', 'prop'])\n      const value = get(action, ['payload', 'value'], null)\n\n      // FIXME: finish implementation once episode saga supports it\n      const simple = [\n        'title',\n        'subtitle',\n        'summary',\n        'duration',\n        'slug',\n        'slug_frozen',\n        'auphonic_webhook_config',\n        'is_auphonic_production_running',\n        'auphonic_plus_transfer_change_time',\n        'soundbite_start',\n        'soundbite_duration',\n        'soundbite_title',\n        'license_name',\n        'license_url',\n        'show',\n        'number',\n        'episode_poster',\n        'poster',\n        'explicit',\n      ]\n\n      if (simple.includes(prop)) {\n        return { ...state, [prop]: value }\n      } else {\n        console.debug('todo', prop, value)\n        return { ...state }\n      }\n    },\n    [SET]: (state: State, action: typeof update): State => ({\n      ...state,\n      slug: get(action, ['payload', 'slug'], state.slug),\n      slug_frozen: get(action, ['payload', 'slug_frozen'], state.slug_frozen),\n      number: get(action, ['payload', 'number'], state.number),\n      duration: get(action, ['payload', 'duration'], state.duration),\n      title: get(action, ['payload', 'title_clean'], state.title),\n      subtitle: get(action, ['payload', 'subtitle'], state.subtitle),\n      summary: get(action, ['payload', 'summary'], state.summary),\n      type: get(action, ['payload', 'type'], state.type),\n      episode_poster: get(action, ['payload', 'episode_poster'], state.episode_poster),\n      poster: get(action, ['payload', 'poster'], state.poster),\n      mnemonic: get(action, ['payload', 'mnemonic'], state.mnemonic),\n      explicit: get(action, ['payload', 'explicit'], state.explicit),\n      auphonic_production_id: get(\n        action,\n        ['payload', 'auphonic_production_id'],\n        state.auphonic_production_id\n      ),\n      is_auphonic_production_running: get(\n        action,\n        ['payload', 'is_auphonic_production_running'],\n        state.is_auphonic_production_running\n      ),\n      auphonic_plus_transfer_change_time: get(\n        action,\n        ['payload', 'auphonic_plus_transfer_change_time'],\n        state.auphonic_plus_transfer_change_time\n      ),\n      auphonic_webhook_config: get(\n        action,\n        ['payload', 'auphonic_webhook_config'],\n        state.auphonic_webhook_config\n      ),\n      soundbite_start: get(action, ['payload', 'soundbite_start'], state.soundbite_start),\n      soundbite_duration: get(action, ['payload', 'soundbite_duration'], state.soundbite_duration),\n      soundbite_title: get(action, ['payload', 'soundbite_title'], state.soundbite_title),\n      license_name: get(action, ['payload', 'license_name'], state.license_name),\n      license_url: get(action, ['payload', 'license_url'], state.license_url),\n      contributions: get(action, ['payload', 'contributions'], state.contributions),\n      show: get(action, ['payload', 'show'], state.show),\n    }),\n    [MOVE_CONTRIBUTION_UP]: (state: State, action: typeof moveContributionUp): State => {\n      const index = state.contributions.findIndex(\n        (contribution) => contribution.position === get(action, ['payload', 'position'])\n      )\n\n      if (index < 1) {\n        return state\n      }\n\n      return {\n        ...state,\n        contributions: arrayMove(state.contributions, index, index - 1).map(\n          (contribution, position) => ({ ...contribution, position })\n        ),\n      }\n    },\n    [MOVE_CONTRIBUTION_DOWN]: (state: State, action: typeof moveContributionDown): State => {\n      const index = state.contributions.findIndex(\n        (contribution) => contribution.position === get(action, ['payload', 'position'])\n      )\n\n      if (index > state.contributions.length) {\n        return state\n      }\n\n      return {\n        ...state,\n        contributions: arrayMove(state.contributions, index, index + 1).map(\n          (contribution, position) => ({ ...contribution, position })\n        ),\n      }\n    },\n    [DELETE_CONTRIBUTION]: (state: State, action: typeof deleteContribution): State => ({\n      ...state,\n      contributions: state.contributions\n        .filter(({ position }) => get(action, ['payload', 'position']) !== position)\n        .map((contribution, position) => ({ ...contribution, position })),\n    }),\n    [UPDATE_CONTRIBUTION]: (state: State, action: typeof updateContribution): State => ({\n      ...state,\n      contributions: state.contributions.map((contribution) => {\n        if (contribution.contributor_id !== get(action, ['payload', 'contributor_id'])) {\n          return contribution\n        }\n\n        return pick(get(action, ['payload'], {}), [\n          'id',\n          'contributor_id',\n          'role_id',\n          'group_id',\n          'position',\n          'comment',\n        ])\n      }),\n    }),\n    [ADD_CONTRIBUTION]: (state: State, action: typeof addContribution) => ({\n      ...state,\n      contributions: [\n        ...state.contributions,\n        {\n          id: null,\n          contributor_id: get(action, ['payload', 'id'], null),\n          role_id: null,\n          group_id: null,\n          position: state.contributions.length,\n          comment: null,\n        },\n      ],\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  id: (state: State) => state.id,\n  slug: (state: State) => state.slug,\n  slugFrozen: (state: State) => state.slug_frozen,\n  duration: (state: State) => state.duration,\n  number: (state: State) => state.number,\n  title: (state: State) => state.title,\n  subtitle: (state: State) => state.subtitle,\n  summary: (state: State) => state.summary,\n  type: (state: State) => state.type,\n  poster: (state: State) => state.poster,\n  episodePoster: (state: State) => state.episode_poster,\n  mnemonic: (state: State) => state.mnemonic,\n  explicit: (state: State) => state.explicit,\n  auphonicProductionId: (state: State) => state.auphonic_production_id,\n  isAuphonicProductionRunning: (state: State) => state.is_auphonic_production_running,\n  auphonicWebhookConfig: (state: State) => state.auphonic_webhook_config,\n  auphonicPlusTransferChangeTime: (state: State) => state.auphonic_plus_transfer_change_time,\n  soundbite_start: (state: State) => state.soundbite_start,\n  soundbite_duration: (state: State) => state.soundbite_duration,\n  soundbite_title: (state: State) => state.soundbite_title,\n  license_name: (state: State) => state.license_name,\n  license_url: (state: State) => state.license_url,\n  contributions: (state: State) => state.contributions,\n  currentShow: (state: State) => state.show,\n}\n"
  },
  {
    "path": "client/src/store/index.ts",
    "content": "declare global {\n  interface Window {\n    __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: Function\n  }\n}\n\nimport { createStore, applyMiddleware, compose, Store } from 'redux'\nimport createSagaMiddleware from 'redux-saga'\n\nimport selectors from './selectors'\nimport reducers from './reducers'\n\nimport { State as LifecycleState } from './lifecycle.store'\nimport { State as ChaptersState } from './chapters.store'\nimport { State as episodeState } from './episode.store'\nimport { State as runtimeState } from './runtime.store'\nimport { State as postState } from './post.store'\nimport { State as transcriptsState } from './transcripts.store'\nimport { State as contributorsState } from './contributors.store'\nimport { State as settingsState } from './settings.store'\nimport { State as podcastState } from './podcast.store'\nimport { State as auphonicState } from './auphonic.store'\nimport { State as progressState } from './progress.store'\nimport { State as mediafilesState } from './mediafiles.store'\nimport { State as plusFileMigrationState } from './plusFileMigration.store'\nimport { State as relatedEpisodesState } from './relatedEpisodes.store'\nimport { State as showsState } from './shows.store'\nimport { State as adminState } from './admin.store'\nimport { State as plusState } from './plus.store'\n\nimport lifecycleSaga from '../sagas/lifecycle.sagas'\nimport podcastSaga from '../sagas/podcast.sagas'\nimport notificationSaga from '../sagas/notification.saga'\nimport chaptersSaga from '../sagas/chapters.sagas'\nimport transcriptsSaga from '../sagas/transcripts.sagas'\nimport contributorsSaga from '../sagas/contributors.sagas'\nimport wordpressSaga from '../sagas/wordpress.sagas'\nimport episodeSaga from '../sagas/episode.sagas'\nimport auphonicSaga from '../sagas/auphonic.sagas'\nimport mediafilesSaga from '../sagas/mediafiles.sagas'\nimport relatedEpisodesSaga from '../sagas/relatedEpisodes.sagas'\nimport showsSaga from '../sagas/shows.sagas'\nimport adminSaga from '../sagas/admin.sagas'\nimport plusFileMigrationSaga from '../sagas/plusFileMigration.sagas'\nimport plusSaga from '../sagas/plus.sagas'\n\nexport interface State {\n  lifecycle: LifecycleState\n  chapters: ChaptersState\n  episode: episodeState\n  runtime: runtimeState\n  post: postState\n  transcripts: transcriptsState\n  contributors: contributorsState\n  settings: settingsState\n  podcast: podcastState\n  auphonic: auphonicState\n  progress: progressState\n  mediafiles: mediafilesState\n  relatedEpisodes: relatedEpisodesState\n  shows: showsState\n  admin: adminState\n  plusFileMigration: plusFileMigrationState\n  plus: plusState\n}\n\nconst sagas = createSagaMiddleware()\n\nconst composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose\nexport const store: Store<State> = createStore(reducers, composeEnhancers(applyMiddleware(sagas)))\n\nsagas.run(lifecycleSaga())\nsagas.run(notificationSaga())\nsagas.run(chaptersSaga())\nsagas.run(transcriptsSaga())\nsagas.run(contributorsSaga())\nsagas.run(wordpressSaga())\nsagas.run(episodeSaga())\nsagas.run(podcastSaga())\nsagas.run(auphonicSaga())\nsagas.run(mediafilesSaga())\nsagas.run(relatedEpisodesSaga())\nsagas.run(showsSaga())\nsagas.run(adminSaga())\nsagas.run(plusFileMigrationSaga())\nsagas.run(plusSaga())\n\nexport { selectors, sagas }\n"
  },
  {
    "path": "client/src/store/lifecycle.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\n\nexport type State = {\n  saved: boolean;\n  changes: boolean;\n  bootstrapped: boolean;\n}\n\nexport const INIT = 'podlove/publisher/INIT'\nexport const READY = 'podlove/publisher/READY'\nexport const SAVE = 'podlove/publisher/SAVE'\nexport const ERROR = 'podlove/publisher/ERROR'\n\nexport const init = createAction<{\n  api?: {\n    base: string;\n    nonce: string;\n  },\n  post?: {\n    id: string;\n  },\n  episode?: {\n    id: string;\n    duration?: string;\n  }\n}>(INIT);\n\nexport const save = createAction<void>(SAVE)\nexport const error = createAction<any>(ERROR)\nexport const ready = createAction<void>(READY)\n\nexport const initialState: State = {\n  saved: false,\n  changes: false,\n  bootstrapped: false\n};\n\nexport const reducer = handleActions({\n  [INIT]: (state: State): State => ({\n    ...state,\n    bootstrapped: true\n  })\n}, initialState);\n\nexport const selectors = {\n  bootstrapped: (state: State) => state.bootstrapped\n}\n"
  },
  {
    "path": "client/src/store/mediafiles.store.ts",
    "content": "import { createAction, handleActions } from 'redux-actions'\n\nexport type MediaFile = {\n  asset_id: number\n  asset: string\n  url: string\n  size: number\n  enable: boolean\n  is_verifying: boolean\n}\n\nexport type FileInfo = {\n  file: File\n  originalName: string\n  newName: string\n  fileExists?: boolean\n}\n\nexport type State = {\n  is_initializing: boolean\n  slug_autogeneration_enabled: boolean\n  files: MediaFile[]\n  selectedFiles: FileInfo[]\n}\n\nexport const initialState: State = {\n  is_initializing: true,\n  slug_autogeneration_enabled: false,\n  files: [],\n  selectedFiles: [],\n}\n\nexport const INIT = 'podlove/publisher/mediafiles/INIT'\nexport const INIT_DONE = 'podlove/publisher/mediafiles/INIT_DONE'\nexport const SET = 'podlove/publisher/mediafiles/SET'\nexport const UPDATE = 'podlove/publisher/mediafiles/UPDATE'\nexport const ENABLE = 'podlove/publisher/mediafiles/ENABLE'\nexport const DISABLE = 'podlove/publisher/mediafiles/DISABLE'\nexport const VERIFY = 'podlove/publisher/mediafiles/VERIFY'\nexport const VERIFY_ALL = 'podlove/publisher/mediafiles/VERIFY_ALL'\nexport const UPLOAD_INTENT = 'podlove/publisher/mediafiles/UPLOAD_INTENT'\nexport const PLUS_UPLOAD_INTENT = 'podlove/publisher/mediafiles/PLUS_UPLOAD_INTENT'\nexport const SET_UPLOAD_URL = 'podlove/publisher/mediafiles/SET_UPLOAD_URL'\nexport const ENABLE_SLUG_AUTOGEN = 'podlove/publisher/mediafiles/ENABLE_SLUG_AUTOGEN'\nexport const DISABLE_SLUG_AUTOGEN = 'podlove/publisher/mediafiles/DISABLE_SLUG_AUTOGEN'\nexport const FILE_SELECTED = 'podlove/publisher/mediafiles/FILE_SELECTED'\nexport const SET_FILE_INFO = 'podlove/publisher/mediafiles/SET_FILE_INFO'\nexport const ADD_SELECTED_FILES = 'podlove/publisher/mediafiles/ADD_SELECTED_FILES'\nexport const REMOVE_SELECTED_FILE = 'podlove/publisher/mediafiles/REMOVE_SELECTED_FILE'\nexport const UNFREEZE_SLUG = 'podlove/publisher/mediafiles/UNFREEZE_SLUG'\n\nexport const init = createAction<void>(INIT)\nexport const initDone = createAction<void>(INIT_DONE)\nexport const set = createAction<MediaFile[]>(SET)\nexport const update = createAction<Partial<MediaFile>>(UPDATE)\nexport const enable = createAction<number>(ENABLE)\nexport const disable = createAction<number>(DISABLE)\nexport const verify = createAction<number>(VERIFY)\nexport const verifyAll = createAction<void>(VERIFY_ALL)\nexport const uploadIntent = createAction<void>(UPLOAD_INTENT)\nexport const plusUploadIntent = createAction<File | null>(PLUS_UPLOAD_INTENT)\nexport const setUploadUrl = createAction<string>(SET_UPLOAD_URL)\nexport const enableSlugAutogen = createAction<void>(ENABLE_SLUG_AUTOGEN)\nexport const disableSlugAutogen = createAction<void>(DISABLE_SLUG_AUTOGEN)\nexport const fileSelected = (files: File[], episodeSlug: string | null) => ({\n  type: FILE_SELECTED,\n  payload: { files, episodeSlug },\n})\n\nexport const addSelectedFiles = createAction<FileInfo[]>(ADD_SELECTED_FILES)\nexport const removeSelectedFile = createAction<string>(REMOVE_SELECTED_FILE)\nexport const unfreezeSlug = createAction<void>(UNFREEZE_SLUG)\n\n// TODO: enable revalidates I think?\nexport const reducer = handleActions(\n  {\n    [INIT_DONE]: (state: State): State => ({\n      ...state,\n      is_initializing: false,\n    }),\n    [SET]: (state: State, action: { type: string; payload: MediaFile[] }): State => ({\n      ...state,\n      files: action.payload,\n    }),\n    [UPDATE]: (state: State, action: { type: string; payload: Partial<MediaFile> }): State => ({\n      ...state,\n      files: state.files.reduce(\n        (result: MediaFile[], file) => [\n          ...result,\n          file.asset_id == action.payload.asset_id ? { ...file, ...action.payload } : file,\n        ],\n        []\n      ),\n    }),\n    [ENABLE]: (state: State, action: { type: string; payload: number }): State => ({\n      ...state,\n      files: state.files.reduce(\n        (result: MediaFile[], file) => [\n          ...result,\n          file.asset_id == action.payload ? { ...file, enable: true } : file,\n        ],\n        []\n      ),\n    }),\n    [DISABLE]: (state: State, action: { type: string; payload: number }): State => ({\n      ...state,\n      files: state.files.reduce(\n        (result: MediaFile[], file) => [\n          ...result,\n          file.asset_id == action.payload ? { ...file, enable: false } : file,\n        ],\n        []\n      ),\n    }),\n    [ENABLE_SLUG_AUTOGEN]: (state: State): State => ({\n      ...state,\n      slug_autogeneration_enabled: true,\n    }),\n    [DISABLE_SLUG_AUTOGEN]: (state: State): State => ({\n      ...state,\n      slug_autogeneration_enabled: false,\n    }),\n    [SET_FILE_INFO]: (\n      state: State,\n      action: {\n        type: string\n        payload: FileInfo[]\n      }\n    ): State => ({\n      ...state,\n      selectedFiles: action.payload,\n    }),\n    [ADD_SELECTED_FILES]: (\n      state: State,\n      action: {\n        type: string\n        payload: FileInfo[]\n      }\n    ): State => ({\n      ...state,\n      selectedFiles: [...state.selectedFiles, ...action.payload],\n    }),\n    [REMOVE_SELECTED_FILE]: (\n      state: State,\n      action: {\n        type: string\n        payload: string\n      }\n    ): State => ({\n      ...state,\n      selectedFiles: state.selectedFiles.filter(f => f.newName !== action.payload),\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  isInitializing: (state: State) => state.is_initializing,\n  slugAutogenerationEnabled: (state: State) => state.slug_autogeneration_enabled,\n  files: (state: State) => state.files,\n  selectedFiles: (state: State) => state.selectedFiles,\n}\n\nexport const actions = {\n  fileSelected: (files: File[], episodeSlug: string | null) => ({\n    type: FILE_SELECTED,\n    payload: { files, episodeSlug },\n  }),\n  setSelectedFiles: (selectedFiles: FileInfo[]) => ({\n    type: SET_FILE_INFO,\n    payload: selectedFiles,\n  }),\n  addSelectedFiles: (selectedFiles: FileInfo[]) => ({\n    type: ADD_SELECTED_FILES,\n    payload: selectedFiles,\n  }),\n  removeSelectedFile: (fileName: string) => ({\n    type: REMOVE_SELECTED_FILE,\n    payload: fileName,\n  }),\n}\n"
  },
  {
    "path": "client/src/store/notification.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\n\nexport const NOTIFY = 'podlove/publisher/NOTIFY'\nexport const notify = createAction<{ type: 'success' | 'warning' | 'error', message: string; }>(NOTIFY)\n\nexport type State = {\n}\n\nexport const initialState: State = {\n};\n\nexport const reducer = handleActions({\n}, initialState);\n\nexport const selectors = {\n}\n"
  },
  {
    "path": "client/src/store/plus.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\nimport * as lifecycle from './lifecycle.store'\n\nexport type PlusFeatures = {\n  fileStorage: boolean\n  feedProxy: boolean\n}\n\nexport type State = {\n  features: PlusFeatures\n  token: string\n  user: {\n    email: string\n  } | null\n  isLoading: boolean\n  isSaving: boolean\n}\n\nexport const initialState: State = {\n  features: {\n    fileStorage: false,\n    feedProxy: false,\n  },\n  token: '',\n  user: null,\n  isLoading: true,\n  isSaving: false,\n}\n\nexport const INIT = 'podlove/publisher/plus/INIT'\nexport const SET_FEATURE = 'podlove/publisher/plus/SET_FEATURE'\nexport const GET_TOKEN = 'podlove/publisher/plus/GET_TOKEN'\nexport const SET_TOKEN = 'podlove/publisher/plus/SET_TOKEN'\nexport const SET_USER = 'podlove/publisher/plus/SET_USER'\nexport const SAVE_TOKEN = 'podlove/publisher/plus/SAVE_TOKEN'\nexport const SET_LOADING = 'podlove/publisher/plus/SET_LOADING'\nexport const SET_SAVING = 'podlove/publisher/plus/SET_SAVING'\n\nexport const init = createAction<void>(INIT)\nexport const setFeature = createAction<{ feature: string; value: boolean }>(SET_FEATURE)\nexport const getToken = createAction<void>(GET_TOKEN)\nexport const setToken = createAction<string>(SET_TOKEN)\nexport const setUser = createAction<{ email: string } | null>(SET_USER)\nexport const saveToken = createAction<string>(SAVE_TOKEN)\nexport const setLoading = createAction<boolean>(SET_LOADING)\nexport const setSaving = createAction<boolean>(SET_SAVING)\n\nexport const reducer = handleActions(\n  {\n    [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({\n      ...state,\n    }),\n    [SET_FEATURE]: (state: State, action: ReturnType<typeof setFeature>): State => ({\n      ...state,\n      features: {\n        ...state.features,\n        [action.payload.feature]: action.payload.value,\n      },\n    }),\n    [SET_TOKEN]: (state: State, action: ReturnType<typeof setToken>): State => ({\n      ...state,\n      token: action.payload,\n    }),\n    [SET_USER]: (state: State, action: ReturnType<typeof setUser>): State => ({\n      ...state,\n      user: action.payload,\n    }),\n    [SET_LOADING]: (state: State, action: ReturnType<typeof setLoading>): State => ({\n      ...state,\n      isLoading: action.payload,\n    }),\n    [SET_SAVING]: (state: State, action: ReturnType<typeof setSaving>): State => ({\n      ...state,\n      isSaving: action.payload,\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  features: (state: State) => state.features,\n  token: (state: State) => state.token,\n  user: (state: State) => state.user,\n  isLoading: (state: State) => state.isLoading,\n  isSaving: (state: State) => state.isSaving,\n}\n"
  },
  {
    "path": "client/src/store/plusFileMigration.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\nimport * as lifecycle from './lifecycle.store'\n\ntype UploadState = 'init' | 'ready' | 'in_progress' | 'finished' | 'error'\n\ntype UploadFile = {\n  name: string\n  localUrl: string\n  remoteUrl: string\n  state: UploadState\n}\n\nexport type EpisodeWithFiles = {\n  episodeName: string\n  files: UploadFile[]\n}\n\nexport type State = {\n  totalState: UploadState\n  progress: number\n  currentEpisodeName: string\n  currentFileName: string\n  episodesWithFiles: EpisodeWithFiles[]\n  isMigrationComplete: boolean\n  showMigrationToolManually: boolean\n}\n\nexport const initialState: State = {\n  totalState: 'init',\n  progress: 0,\n  currentEpisodeName: '',\n  currentFileName: '',\n  episodesWithFiles: [],\n  isMigrationComplete: false,\n  showMigrationToolManually: false,\n}\n\nexport const INIT = 'podlove/publisher/plusFileMigration/INIT'\nexport const SET_EPISODES_WITH_FILES = 'podlove/publisher/plusFileMigration/SET_EPISODES_WITH_FILES'\nexport const SET_TOTAL_STATE = 'podlove/publisher/plusFileMigration/SET_TOTAL_STATE'\nexport const START_MIGRATION = 'podlove/publisher/plusFileMigration/START_MIGRATION'\nexport const SET_CURRENT_METADATA = 'podlove/publisher/plusFileMigration/SET_CURRENT_METADATA'\nexport const SET_FILE_STATE = 'podlove/publisher/plusFileMigration/SET_FILE_STATE'\nexport const SET_PROGRESS = 'podlove/publisher/plusFileMigration/SET_PROGRESS'\nexport const SET_MIGRATION_COMPLETE = 'podlove/publisher/plusFileMigration/SET_MIGRATION_COMPLETE'\nexport const TOGGLE_MIGRATION_TOOL_MANUALLY = 'podlove/publisher/plusFileMigration/TOGGLE_MIGRATION_TOOL_MANUALLY'\n\nexport const init = createAction<void>(INIT)\nexport const setEpisodesWithFiles =\n  createAction<{ episodesWithFiles: EpisodeWithFiles[] }>(SET_EPISODES_WITH_FILES)\nexport const setTotalState = createAction<{ totalState: UploadState }>(SET_TOTAL_STATE)\nexport const startMigration = createAction<void>(START_MIGRATION)\nexport const setCurrentMetadata =\n  createAction<{ currentEpisodeName: string; currentFileName: string }>(SET_CURRENT_METADATA)\nexport const setFileState = createAction<{ filename: string; state: UploadState }>(SET_FILE_STATE)\nexport const setProgress = createAction<{ progress: number }>(SET_PROGRESS)\nexport const setMigrationComplete =\n  createAction<{ isMigrationComplete: boolean }>(SET_MIGRATION_COMPLETE)\nexport const toggleMigrationToolManually = createAction<void>(TOGGLE_MIGRATION_TOOL_MANUALLY)\n\nexport const reducer = handleActions(\n  {\n    [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({\n      ...state,\n    }),\n    [SET_EPISODES_WITH_FILES]: (\n      state: State,\n      action: ReturnType<typeof setEpisodesWithFiles>\n    ): State => ({\n      ...state,\n      episodesWithFiles: action.payload.episodesWithFiles,\n    }),\n    [SET_TOTAL_STATE]: (state: State, action: ReturnType<typeof setTotalState>): State => ({\n      ...state,\n      totalState: action.payload.totalState,\n    }),\n    [SET_CURRENT_METADATA]: (\n      state: State,\n      action: ReturnType<typeof setCurrentMetadata>\n    ): State => ({\n      ...state,\n      currentEpisodeName: action.payload.currentEpisodeName,\n      currentFileName: action.payload.currentFileName,\n    }),\n    [SET_FILE_STATE]: (state: State, action: ReturnType<typeof setFileState>): State => ({\n      ...state,\n      episodesWithFiles: state.episodesWithFiles.map((episode) => ({\n        ...episode,\n        files: episode.files.map((file) => ({\n          ...file,\n          state: file.name === action.payload.filename ? action.payload.state : file.state,\n        })),\n      })),\n    }),\n    [SET_PROGRESS]: (state: State, action: ReturnType<typeof setProgress>): State => ({\n      ...state,\n      progress: action.payload.progress,\n    }),\n    [SET_MIGRATION_COMPLETE]: (\n      state: State,\n      action: ReturnType<typeof setMigrationComplete>\n    ): State => ({\n      ...state,\n      isMigrationComplete: action.payload.isMigrationComplete,\n    }),\n    [TOGGLE_MIGRATION_TOOL_MANUALLY]: (state: State): State => ({\n      ...state,\n      showMigrationToolManually: !state.showMigrationToolManually,\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  totalState: (state: State) => state.totalState,\n  progress: (state: State) => state.progress,\n  currentEpisodeName: (state: State) => state.currentEpisodeName,\n  currentFileName: (state: State) => state.currentFileName,\n  episodesWithFiles: (state: State) => state.episodesWithFiles,\n  isMigrationComplete: (state: State) => state.isMigrationComplete,\n  showMigrationToolManually: (state: State) => state.showMigrationToolManually,\n}\n"
  },
  {
    "path": "client/src/store/podcast.store.ts",
    "content": "import { get } from 'lodash'\nimport { handleActions } from 'redux-actions'\nimport { createAction } from 'redux-actions'\n\nexport const INIT = 'podlove/publisher/podcast/INIT'\nexport const SET = 'podlove/publisher/podcast/SET'\nexport const SAVED = 'podlove/publisher/podcasr/SAVED'\nexport const UPDATE = 'podlove/publisher/podcast/UPDATE'\n\n\nexport type State = {\n  title: string | null\n  subtitle: string | null\n  summary: string | null\n  mnemonic: string | null\n  itunes_type: string | null\n  author_name: string | null\n  poster: string | null\n  link: string | null\n  license_name: string | null\n  license_url: string | null\n}\n\nexport const initialState: State = {\n  title: null,\n  subtitle: null,\n  summary: null,\n  mnemonic: null,\n  itunes_type: null,\n  author_name: null,\n  poster: null,\n  link: null,\n  license_name: null,\n  license_url: null\n}\n\nexport const init = createAction<void>(INIT)\nexport const set = createAction<Partial<State>>(SET)\nexport const saved = createAction<object>(SAVED)\nexport const update = createAction<{ prop: string; value: any }>(UPDATE)\n\nexport const reducer = handleActions(\n  {\n    [SET]: (state: State, action: { payload: Partial<State> }): State => ({\n      title: get(action , ['payload', 'title'], state.title),\n      subtitle: get(action , ['payload', 'subtitle'], state.subtitle),\n      summary: get(action , ['payload', 'summary'], state.summary),\n      mnemonic: get(action , ['payload', 'mnemonic'], state.mnemonic),\n      itunes_type: get(action , ['payload', 'itunes_type'], state.itunes_type),\n      author_name: get(action , ['payload', 'author_name'], state.author_name),\n      poster: get(action , ['payload', 'poster'], state.poster),\n      link: get(action , ['payload', 'link'], state.link),\n      license_name: get(action , ['payload', 'license_name'], state.license_name),\n      license_url: get(action , ['payload', 'license_url'], state.license_url),\n    }),\n    [UPDATE]: (state: State, action: typeof update): State => {\n      const prop = get(action, ['payload', 'prop'])\n      const value = get(action, ['payload', 'value'], null)\n\n      const simple = [\n        'title',\n        'subtitle',\n        'summary',\n        'author_name',\n        'podcast_email',\n        'funding_url',\n        'funding_label',\n        'license_name',\n        'license_url',\n      ]\n\n      if (simple.includes(prop)) {\n        return { ...state, [prop]: value }\n      } else {\n        console.debug('todo', prop, value)\n        return { ...state }\n      }\n    },\n  },\n  initialState\n)\n\nexport const selectors = {\n  title: (state: State) => state.title,\n  subtitle: (state: State) => state.subtitle,\n  summary: (state: State) => state.summary,\n  mnemonic: (state: State) => state.mnemonic,\n  itunesType: (state: State) => state.itunes_type,\n  author: (state: State) => state.author_name,\n  poster: (state: State) => state.poster,\n  link: (state: State) => state.link,\n  license_name: (state: State) => state.license_name,\n  license_url: (state: State) => state.license_url,\n}\n"
  },
  {
    "path": "client/src/store/post.store.ts",
    "content": "import { get } from 'lodash'\nimport { handleActions } from 'redux-actions'\nimport { INIT, init } from './lifecycle.store'\nimport * as wordpressStore from './wordpress.store'\n\nexport type State = {\n  id: string | null\n  title: string | null\n  featured_media: object | null\n}\n\nexport const initialState: State = {\n  id: null,\n  title: null,\n  featured_media: null,\n}\n\nexport const reducer = handleActions(\n  {\n    [INIT]: (state: State, action: typeof init): State => ({\n      ...state,\n      id: get(action, ['payload', 'post', 'id'], null),\n      title: get(action, ['payload', 'post', 'title'], null),\n      featured_media: get(action, ['payload', 'post', 'featured_media'], null),\n    }),\n    [wordpressStore.UPDATE]: (\n      state: State,\n      action: { type: string; payload: { prop: string; value: any } }\n    ): State => {\n      const prop = get(action, ['payload', 'prop'])\n      const value = get(action, ['payload', 'value'], null)\n      const allowed_props = ['title', 'featured_media']\n\n      if (allowed_props.includes(prop)) {\n        return { ...state, [prop]: value }\n      } else {\n        return { ...state }\n      }\n    },\n  },\n  initialState\n)\n\nexport const selectors = {\n  id: (state: State) => state.id,\n  title: (state: State) => state.title,\n  featured_media: (state: State) => state.featured_media,\n}\n"
  },
  {
    "path": "client/src/store/progress.store.ts",
    "content": "import { createAction, handleActions } from 'redux-actions'\n\nexport type ProgressStatus = 'init' | 'in_progress' | 'finished' | 'error'\n\nexport type ProgressItem = {\n  progress: number\n  status: ProgressStatus\n  message?: string\n}\n\nexport type State = {\n  [key: string]: ProgressItem\n}\n\nexport type SetProgressPayload = {\n  key: string\n  progress: number\n  status?: ProgressStatus\n  message?: string\n}\n\nexport type SetProgressStatusPayload = {\n  key: string\n  status: ProgressStatus\n  message?: string\n}\n\nconst initialState: State = {}\n\nexport const SET_PROGRESS = 'podlove/publisher/progress/SET_PROGRESS'\nexport const SET_PROGRESS_STATUS = 'podlove/publisher/progress/SET_PROGRESS_STATUS'\nexport const RESET_PROGRESS = 'podlove/publisher/progress/RESET_PROGRESS'\n\nexport const setProgress = createAction<SetProgressPayload>(SET_PROGRESS)\nexport const setProgressStatus = createAction<SetProgressStatusPayload>(SET_PROGRESS_STATUS)\nexport const resetProgress = createAction<string>(RESET_PROGRESS)\n\nexport const reducer = handleActions(\n  {\n    [SET_PROGRESS]: (state: State, action: { type: string; payload: SetProgressPayload }) => {\n      const { key, progress, status, message } = action.payload\n      const currentItem = state[key] || { progress: 0, status: 'init' }\n\n      return {\n        ...state,\n        [key]: {\n          ...currentItem,\n          progress,\n          ...(status && { status }),\n          ...(message !== undefined && { message }),\n        },\n      }\n    },\n    [SET_PROGRESS_STATUS]: (\n      state: State,\n      action: { type: string; payload: SetProgressStatusPayload }\n    ) => {\n      const { key, status, message } = action.payload\n      const currentItem = state[key] || { progress: 0, status: 'init' }\n\n      return {\n        ...state,\n        [key]: {\n          ...currentItem,\n          status,\n          ...(message !== undefined && { message }),\n        },\n      }\n    },\n    [RESET_PROGRESS]: (state: State, action: { type: string; payload: string }) => {\n      const newState = { ...state }\n      delete newState[action.payload]\n      return newState\n    },\n  },\n  initialState\n)\n\nconst progress = (state: State, key: string): number => state[key]?.progress ?? 0\nconst status = (state: State, key: string): ProgressStatus => state[key]?.status ?? 'init'\nconst message = (state: State, key: string): string | undefined => state[key]?.message\n\nexport const selectors = {\n  progress,\n  status,\n  message,\n}\n"
  },
  {
    "path": "client/src/store/reducers.ts",
    "content": "import { combineReducers } from 'redux'\nimport * as lifecycleStore from './lifecycle.store'\nimport * as chaptersStore from './chapters.store'\nimport * as episodeStore from './episode.store'\nimport * as runtimeStore from './runtime.store'\nimport * as postStore from './post.store'\nimport * as transcriptsStore from './transcripts.store'\nimport * as contributorsStore from './contributors.store'\nimport * as settingsStore from './settings.store'\nimport * as podcastStore from './podcast.store'\nimport * as auphonicStore from './auphonic.store'\nimport * as progressStore from './progress.store'\nimport * as mediafilesStore from './mediafiles.store'\nimport * as relatedEpisodesStore from './relatedEpisodes.store'\nimport * as showsStore from './shows.store'\nimport * as adminStore from './admin.store'\nimport * as plusFileMigrationStore from './plusFileMigration.store'\nimport * as plusStore from './plus.store'\n\nexport default combineReducers({\n  lifecycle: lifecycleStore.reducer,\n  chapters: chaptersStore.reducer,\n  episode: episodeStore.reducer,\n  runtime: runtimeStore.reducer,\n  post: postStore.reducer,\n  transcripts: transcriptsStore.reducer,\n  contributors: contributorsStore.reducer,\n  settings: settingsStore.reducer,\n  podcast: podcastStore.reducer,\n  auphonic: auphonicStore.reducer,\n  progress: progressStore.reducer,\n  mediafiles: mediafilesStore.reducer,\n  relatedEpisodes: relatedEpisodesStore.reducer,\n  shows: showsStore.reducer,\n  admin: adminStore.reducer,\n  plusFileMigration: plusFileMigrationStore.reducer,\n  plus: plusStore.reducer,\n})\n"
  },
  {
    "path": "client/src/store/relatedEpisodes.store.ts",
    "content": "import { handleActions, createAction } from \"redux-actions\";\nimport { PodloveEpisodeList } from \"../types/relatedEpisodes.types\";\nimport * as lifecycle from './lifecycle.store'\n\nexport type State = {\n  episodeList: PodloveEpisodeList[]\n  selectEpisodes: Number[]\n}\n\nexport const initialState: State = {\n  episodeList: [],\n  selectEpisodes: []\n}\n\nexport const INIT = 'podlove/publisher/relatedEpisodes/INIT'\nexport const SET_EPISODE_LIST = 'podlove/publisher/relatedEpisodes/SET_EPISODE_LIST'\nexport const SET_SELECTED_EPISODES = 'podlove/publisher/relatedEpisodes/SET_SELECTED_EPISODES'\nexport const UPDATE_RELATED_EPISODES = 'podlove/publisher/relatedEpisodes/UPDATE_RELATED_EPISODES'\n\nexport const init = createAction<void>(INIT);\nexport const setEpisodeList = createAction<PodloveEpisodeList[]>(SET_EPISODE_LIST);\nexport const setSelectedEpisodes = createAction<Number[]>(SET_SELECTED_EPISODES);\nexport const updateRelatedEpisodes = createAction<void>(UPDATE_RELATED_EPISODES);\n\nexport const reducer = handleActions(\n  {\n    [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({\n      ...state,\n\n    }),\n    [SET_EPISODE_LIST]: (state: State, action: { payload: PodloveEpisodeList[] }): State => ({\n      ...state,\n      episodeList: action.payload,\n    }),\n    [SET_SELECTED_EPISODES]: (state: State, action: { payload: Number[]}): State => ({\n      ...state,\n      selectEpisodes: action.payload,\n    }),\n  }, initialState\n)\n\nexport const selectors = {\n  episodeList: (state: State) => state.episodeList,\n  selectEpisodes: (state: State) => state.selectEpisodes,\n}\n"
  },
  {
    "path": "client/src/store/runtime.store.ts",
    "content": "import { get } from 'lodash';\nimport { handleActions, } from 'redux-actions'\nimport { INIT, init } from './lifecycle.store';\n\nexport type State = {\n  baseUrl: string | null;\n  api: {\n    nonce: string | null;\n    base: string | null;\n    auth: string | null;\n    bearer: string | null;\n  }\n}\n\nexport const initialState: State = {\n  baseUrl: null,\n  api: {\n    nonce: null,\n    base: null,\n    auth: null,\n    bearer: null\n  }\n};\n\nexport const reducer = handleActions({\n  [INIT]: (state: State, action: typeof init): State => ({\n    ...state,\n    baseUrl: get(action, ['payload', 'baseUrl'], null),\n    api: {\n      base: get(action, ['payload', 'api', 'base'], null),\n      nonce: get(action, ['payload', 'api', 'nonce'], null),\n      auth: get(action, ['payload', 'api', 'auth'], null),\n      bearer: get(action, ['payload', 'api', 'bearer'], null),\n    }\n  })\n}, initialState);\n\nexport const selectors = {\n  baseUrl: (state: State) => state.baseUrl,\n  nonce: (state: State) => state.api.nonce,\n  base: (state: State) => state.api.base,\n  auth: (state: State) => state.api.auth,\n  bearer: (state: State) => state.api.bearer,\n}\n"
  },
  {
    "path": "client/src/store/selectors.ts",
    "content": "import { createSelector } from 'reselect'\nimport { State } from './index'\nimport * as lifecycleStore from './lifecycle.store'\nimport * as chaptersStore from './chapters.store'\nimport * as episodeStore from './episode.store'\nimport * as runtimeStore from './runtime.store'\nimport * as postStore from './post.store'\nimport * as transcriptsStore from './transcripts.store'\nimport * as contributorsStore from './contributors.store'\nimport * as settingsStore from './settings.store'\nimport * as podcastStore from './podcast.store'\nimport * as plusFileMigrationStore from './plusFileMigration.store'\nimport * as plusStore from './plus.store'\nimport * as auphonicStore from './auphonic.store'\nimport * as progressStore from './progress.store'\nimport * as mediafilesStore from './mediafiles.store'\nimport * as relatedEpisodesStore from './relatedEpisodes.store'\nimport * as showsStore from './shows.store'\nimport * as adminStore from './admin.store'\n\nconst root = {\n  lifecycle: (state: State) => state.lifecycle,\n  chapters: (state: State) => state.chapters,\n  podcast: (state: State) => state.podcast,\n  episode: (state: State) => state.episode,\n  runtime: (state: State) => state.runtime,\n  post: (state: State) => state.post,\n  transcripts: (state: State) => state.transcripts,\n  contributors: (state: State) => state.contributors,\n  auphonic: (state: State) => state.auphonic,\n  progress: (state: State) => state.progress,\n  mediafiles: (state: State) => state.mediafiles,\n  settings: (state: State) => state.settings,\n  relatedEpisodes: (state: State) => state.relatedEpisodes,\n  shows: (state: State) => state.shows,\n  admin: (state: State) => state.admin,\n  plusFileMigration: (state: State) => state.plusFileMigration,\n  plus: (state: State) => state.plus,\n}\n\nconst lifecycle = {\n  bootstrapped: createSelector(root.lifecycle, lifecycleStore.selectors.bootstrapped),\n}\n\nconst auphonic = {\n  token: createSelector(root.auphonic, auphonicStore.selectors.token),\n  productionId: createSelector(root.auphonic, auphonicStore.selectors.productionId),\n  productions: createSelector(root.auphonic, auphonicStore.selectors.productions),\n  production: createSelector(root.auphonic, auphonicStore.selectors.production),\n  presets: createSelector(root.auphonic, auphonicStore.selectors.presets),\n  preset: createSelector(root.auphonic, auphonicStore.selectors.preset),\n  productionPayload: createSelector(root.auphonic, auphonicStore.selectors.productionPayload),\n  incomingServices: createSelector(root.auphonic, auphonicStore.selectors.incomingServices),\n  outgoingServices: createSelector(root.auphonic, auphonicStore.selectors.outgoingServices),\n  serviceFiles: createSelector(root.auphonic, auphonicStore.selectors.serviceFiles),\n  tracks: createSelector(root.auphonic, auphonicStore.selectors.tracks),\n  fileSelections: createSelector(root.auphonic, auphonicStore.selectors.fileSelections),\n  currentFileSelection: createSelector(root.auphonic, auphonicStore.selectors.currentFileSelection),\n  isSaving: createSelector(root.auphonic, auphonicStore.selectors.isSaving),\n  isInitializing: createSelector(root.auphonic, auphonicStore.selectors.isInitializing),\n  publishWhenDone: createSelector(root.auphonic, auphonicStore.selectors.publishWhenDone),\n  plusTransferStatus: createSelector(root.auphonic, auphonicStore.selectors.plusTransferStatus),\n  plusTransferFiles: createSelector(root.auphonic, auphonicStore.selectors.plusTransferFiles),\n  plusTransferErrors: createSelector(root.auphonic, auphonicStore.selectors.plusTransferErrors),\n}\n\nconst progress = {\n  progress: createSelector(\n    [root.progress, (_state: any, key: string) => key],\n    progressStore.selectors.progress\n  ),\n  status: createSelector(\n    [root.progress, (_state: any, key: string) => key],\n    progressStore.selectors.status\n  ),\n  message: createSelector(\n    [root.progress, (_state: any, key: string) => key],\n    progressStore.selectors.message\n  ),\n}\n\nconst podcast = {\n  title: createSelector(root.podcast, podcastStore.selectors.title),\n  subtitle: createSelector(root.podcast, podcastStore.selectors.subtitle),\n  summary: createSelector(root.podcast, podcastStore.selectors.summary),\n  mnemonic: createSelector(root.podcast, podcastStore.selectors.mnemonic),\n  itunesType: createSelector(root.podcast, podcastStore.selectors.itunesType),\n  author: createSelector(root.podcast, podcastStore.selectors.author),\n  poster: createSelector(root.podcast, podcastStore.selectors.poster),\n  link: createSelector(root.podcast, podcastStore.selectors.link),\n  license_name: createSelector(root.podcast, podcastStore.selectors.license_name),\n  license_url: createSelector(root.podcast, podcastStore.selectors.license_url),\n}\n\nconst chapters = {\n  list: createSelector(root.chapters, chaptersStore.selectors.chapters),\n  selected: createSelector(root.chapters, chaptersStore.selectors.selected),\n  selectedIndex: createSelector(root.chapters, chaptersStore.selectors.selectedIndex),\n}\n\nconst contributors = {\n  contributors: createSelector(root.contributors, contributorsStore.selectors.contributors),\n  roles: createSelector(root.contributors, contributorsStore.selectors.roles),\n  groups: createSelector(root.contributors, contributorsStore.selectors.groups),\n}\n\nconst episode = {\n  id: createSelector(root.episode, episodeStore.selectors.id),\n  slug: createSelector(root.episode, episodeStore.selectors.slug),\n  slugFrozen: createSelector(root.episode, episodeStore.selectors.slugFrozen),\n  duration: createSelector(root.episode, episodeStore.selectors.duration),\n  number: createSelector(root.episode, episodeStore.selectors.number),\n  title: createSelector(root.episode, episodeStore.selectors.title),\n  subtitle: createSelector(root.episode, episodeStore.selectors.subtitle),\n  summary: createSelector(root.episode, episodeStore.selectors.summary),\n  type: createSelector(root.episode, episodeStore.selectors.type),\n  poster: createSelector(root.episode, episodeStore.selectors.poster),\n  episodePoster: createSelector(root.episode, episodeStore.selectors.episodePoster),\n  effectivePoster: createSelector(\n    createSelector(root.episode, episodeStore.selectors.episodePoster),\n    createSelector(root.episode, episodeStore.selectors.poster),\n    podcast.poster,\n    (episodePoster, poster, podcastPoster) => episodePoster || poster || podcastPoster\n  ),\n  mnemonic: createSelector(root.episode, episodeStore.selectors.mnemonic),\n  explicit: createSelector(root.episode, episodeStore.selectors.explicit),\n  soundbite_start: createSelector(root.episode, episodeStore.selectors.soundbite_start),\n  soundbite_duration: createSelector(root.episode, episodeStore.selectors.soundbite_duration),\n  soundbite_title: createSelector(root.episode, episodeStore.selectors.soundbite_title),\n  auphonicProductionId: createSelector(root.episode, episodeStore.selectors.auphonicProductionId),\n  isAuphonicProductionRunning: createSelector(\n    root.episode,\n    episodeStore.selectors.isAuphonicProductionRunning\n  ),\n  auphonicWebhookConfig: createSelector(root.episode, episodeStore.selectors.auphonicWebhookConfig),\n  auphonicPlusTransferChangeTime: createSelector(\n    root.episode,\n    episodeStore.selectors.auphonicPlusTransferChangeTime\n  ),\n  license_name: createSelector(root.episode, episodeStore.selectors.license_name),\n  license_url: createSelector(root.episode, episodeStore.selectors.license_url),\n  contributions: createSelector(\n    createSelector(root.episode, episodeStore.selectors.contributions),\n    contributors.contributors,\n    (contributions, list) => {\n      const result = contributions.map((contribution) => ({\n        ...contribution,\n        ...(contribution.contributor_id\n          ? list.find(({ id }) => id.toString() === contribution.contributor_id.toString())\n          : {}),\n      }))\n\n      return result\n    }\n  ),\n  currentShow: createSelector(root.episode, episodeStore.selectors.currentShow),\n}\n\nconst mediafiles = {\n  isInitializing: createSelector(root.mediafiles, mediafilesStore.selectors.isInitializing),\n  files: createSelector(root.mediafiles, mediafilesStore.selectors.files),\n  selectedFiles: createSelector(root.mediafiles, mediafilesStore.selectors.selectedFiles),\n  slugAutogenerationEnabled: createSelector(\n    root.mediafiles,\n    mediafilesStore.selectors.slugAutogenerationEnabled\n  ),\n}\n\nconst runtime = {\n  baseUrl: createSelector(root.runtime, runtimeStore.selectors.baseUrl),\n  nonce: createSelector(root.runtime, runtimeStore.selectors.nonce),\n  base: createSelector(root.runtime, runtimeStore.selectors.base),\n  auth: createSelector(root.runtime, runtimeStore.selectors.auth),\n  bearer: createSelector(root.runtime, runtimeStore.selectors.bearer),\n}\n\nconst post = {\n  id: createSelector(root.post, postStore.selectors.id),\n  title: createSelector(root.post, postStore.selectors.title),\n  featuredMedia: createSelector(root.post, postStore.selectors.featured_media),\n}\n\nconst transcripts = {\n  list: createSelector(root.transcripts, transcriptsStore.selectors.transcripts),\n  voices: createSelector(root.transcripts, transcriptsStore.selectors.voices),\n}\n\nconst settings = {\n  autoGenerateEpisodeTitle: createSelector(\n    root.settings,\n    settingsStore.selectors.autoGenerateEpisodeTitle\n  ),\n  blogTitleTemplate: createSelector(root.settings, settingsStore.selectors.blogTitleTemplate),\n  episodeNumberPadding: createSelector(root.settings, settingsStore.selectors.episodeNumberPadding),\n  mediaFileBaseUri: createSelector(root.settings, settingsStore.selectors.mediaFileBaseUri),\n  imageAsset: createSelector(root.settings, settingsStore.selectors.imageAsset),\n  enableEpisodeExplicit: createSelector(\n    root.settings,\n    settingsStore.selectors.enableEpisodeExplicit\n  ),\n  enablePlusStorage: createSelector(root.settings, settingsStore.selectors.enablePlusStorage),\n  modules: createSelector(root.settings, settingsStore.selectors.modules),\n}\n\nconst relatedEpisodes = {\n  episodeList: createSelector(root.relatedEpisodes, relatedEpisodesStore.selectors.episodeList),\n  selectEpisode: createSelector(\n    root.relatedEpisodes,\n    relatedEpisodesStore.selectors.selectEpisodes\n  ),\n}\n\nconst shows = {\n  list: createSelector(root.shows, showsStore.selectors.shows),\n}\n\nconst admin = {\n  bannerHide: createSelector(root.admin, adminStore.selectors.bannerHide),\n  type: createSelector(root.admin, adminStore.selectors.type),\n  feedUrl: createSelector(root.admin, adminStore.selectors.feedUrl),\n}\n\nconst plusFileMigration = {\n  totalState: createSelector(root.plusFileMigration, plusFileMigrationStore.selectors.totalState),\n  progress: createSelector(root.plusFileMigration, plusFileMigrationStore.selectors.progress),\n  currentEpisodeName: createSelector(\n    root.plusFileMigration,\n    plusFileMigrationStore.selectors.currentEpisodeName\n  ),\n  currentFileName: createSelector(\n    root.plusFileMigration,\n    plusFileMigrationStore.selectors.currentFileName\n  ),\n  episodesWithFiles: createSelector(\n    root.plusFileMigration,\n    plusFileMigrationStore.selectors.episodesWithFiles\n  ),\n  isMigrationComplete: createSelector(\n    root.plusFileMigration,\n    plusFileMigrationStore.selectors.isMigrationComplete\n  ),\n  showMigrationToolManually: createSelector(\n    root.plusFileMigration,\n    plusFileMigrationStore.selectors.showMigrationToolManually\n  ),\n}\n\nconst plus = {\n  features: createSelector(root.plus, plusStore.selectors.features),\n  token: createSelector(root.plus, plusStore.selectors.token),\n  user: createSelector(root.plus, plusStore.selectors.user),\n  isLoading: createSelector(root.plus, plusStore.selectors.isLoading),\n  isSaving: createSelector(root.plus, plusStore.selectors.isSaving),\n}\n\nexport default {\n  lifecycle,\n  podcast,\n  chapters,\n  episode,\n  runtime,\n  post,\n  transcripts,\n  contributors,\n  settings,\n  auphonic,\n  progress,\n  mediafiles,\n  relatedEpisodes,\n  shows,\n  admin,\n  plusFileMigration,\n  plus,\n}\n"
  },
  {
    "path": "client/src/store/settings.store.ts",
    "content": "import { get } from 'lodash'\nimport { handleActions } from 'redux-actions'\nimport { init, INIT } from './lifecycle.store'\n\ntype TrackingMode = 'ptm_analytics'\ntype TrackingWindow = 'daily'\n\nexport interface State {\n  plus: {\n    storage_enabled: boolean | null\n  }\n  metadata: {\n    enable_episode_explicit: boolean | null\n    enable_episode_license: boolean | null\n    enable_episode_recording_date: boolean | null\n  }\n  tracking: {\n    mode: TrackingMode | null\n    window: TrackingWindow | null\n  }\n  website: {\n    blog_title_template: string | null\n    custom_episode_slug: string | null\n    enable_generated_blog_post_title: boolean | null\n    episode_archive: boolean | null\n    episode_archive_slug: string | null\n    episode_number_padding: string | null\n    feeds_skip_redirect: boolean | null\n    hide_wp_feed_discovery: boolean | null\n    landing_page: string | null\n    merge_episodes: boolean | null\n    ssl_verify_peer: boolean | null\n    url_template: string | null\n    use_post_permastruct: boolean | null\n  }\n  assets: {\n    image: null | 'podcast-cover' | 'post-thumbnail' | 'manual'\n    chapter: null | 'none' | 'manual'\n    transcript: null | 'manual'\n  }\n  media: {\n    base_uri: null | string\n  }\n  modules: string[]\n}\n\nexport const initialState: State = {\n  plus: {\n    storage_enabled: null,\n  },\n  metadata: {\n    enable_episode_explicit: null,\n    enable_episode_license: null,\n    enable_episode_recording_date: null,\n  },\n  tracking: {\n    mode: null,\n    window: null,\n  },\n  website: {\n    blog_title_template: null,\n    custom_episode_slug: null,\n    enable_generated_blog_post_title: null,\n    episode_archive: null,\n    episode_archive_slug: null,\n    episode_number_padding: null,\n    feeds_skip_redirect: null,\n    hide_wp_feed_discovery: null,\n    landing_page: null,\n    merge_episodes: null,\n    ssl_verify_peer: null,\n    url_template: null,\n    use_post_permastruct: null,\n  },\n  assets: {\n    image: null,\n    chapter: null,\n    transcript: null,\n  },\n  media: {\n    base_uri: null,\n  },\n  modules: [],\n}\n\nconst normalizeAssignmentImage = (\n  input: string\n): null | 'podcast-cover' | 'post-thumbnail' | 'manual' => {\n  switch (input) {\n    case '0':\n      return 'podcast-cover'\n    case 'post-thumbnail':\n      return 'post-thumbnail'\n    case 'manual':\n      return 'manual'\n    default:\n      return null\n  }\n}\n\nconst normalizeAssignmentChapter = (input: string): null | 'none' | 'manual' => {\n  switch (input) {\n    case '0':\n      return 'none'\n    case 'manual':\n      return 'manual'\n    default:\n      return null\n  }\n}\n\nexport const reducer = handleActions(\n  {\n    [INIT]: (state: State, action: typeof init): State => ({\n      ...state,\n      plus: {\n        storage_enabled: get(action, ['payload', 'plus', 'storage_enabled'], null) === true,\n      },\n      metadata: {\n        enable_episode_explicit:\n          get(\n            action,\n            ['payload', 'expert_settings', 'metadata', 'enable_episode_explicit'],\n            null\n          ) === '1',\n        enable_episode_license:\n          get(\n            action,\n            ['payload', 'expert_settings', 'metadata', 'enable_episode_license'],\n            null\n          ) === '1',\n        enable_episode_recording_date:\n          get(\n            action,\n            ['payload', 'expert_settings', 'metadata', 'enable_episode_recording_date'],\n            null\n          ) === '1',\n      },\n      tracking: {\n        mode: get(action, ['payload', 'expert_settings', 'tracking', 'mode'], null),\n        window: get(action, ['payload', 'expert_settings', 'tracking', 'mode'], null),\n      },\n      media: {\n        base_uri: get(action, ['payload', 'media', 'base_uri'], null),\n      },\n      website: {\n        blog_title_template: get(\n          action,\n          ['payload', 'expert_settings', 'website', 'blog_title_template'],\n          null\n        ),\n        custom_episode_slug: get(\n          action,\n          ['payload', 'expert_settings', 'website', 'custom_episode_slug'],\n          null\n        ),\n        enable_generated_blog_post_title:\n          get(\n            action,\n            ['payload', 'expert_settings', 'website', 'enable_generated_blog_post_title'],\n            null\n          ) === 'on',\n        episode_archive:\n          get(action, ['payload', 'expert_settings', 'website', 'episode_archive'], null) === 'on',\n        episode_archive_slug: get(\n          action,\n          ['payload', 'expert_settings', 'website', 'episode_archive_slug'],\n          null\n        ),\n        episode_number_padding: get(\n          action,\n          ['payload', 'expert_settings', 'website', 'episode_number_padding'],\n          null\n        ),\n        feeds_skip_redirect:\n          get(action, ['payload', 'expert_settings', 'website', 'feeds_skip_redirect'], null) ===\n          'on',\n        hide_wp_feed_discovery:\n          get(action, ['payload', 'expert_settings', 'website', 'hide_wp_feed_discovery'], null) ===\n          'on',\n        landing_page: get(action, ['payload', 'expert_settings', 'website', 'landing_page'], null),\n        merge_episodes:\n          get(action, ['payload', 'expert_settings', 'website', 'merge_episodes'], null) === 'on',\n        ssl_verify_peer:\n          get(action, ['payload', 'expert_settings', 'website', 'ssl_verify_peer'], null) === 'on',\n        url_template: get(action, ['payload', 'expert_settings', 'website', 'url_template'], null),\n        use_post_permastruct:\n          get(action, ['payload', 'expert_settings', 'website', 'use_post_permastruct'], null) ===\n          'on',\n      },\n      assets: {\n        image: normalizeAssignmentImage(get(action, ['payload', 'assignments', 'image'], null)),\n        chapter: normalizeAssignmentChapter(\n          get(action, ['payload', 'assignments', 'chapter'], null)\n        ),\n        transcript: get(action, ['payload', 'assignments', 'transcript'], null),\n      },\n      modules: get(action, ['payload', 'modules']),\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  autoGenerateEpisodeTitle: (state: State) => state.website.enable_generated_blog_post_title,\n  blogTitleTemplate: (state: State) => state.website.blog_title_template,\n  episodeNumberPadding: (state: State) => state.website.episode_number_padding,\n  imageAsset: (state: State) => state.assets.image,\n  enableEpisodeExplicit: (state: State) => state.metadata.enable_episode_explicit,\n  enablePlusStorage: (state: State) => state.plus.storage_enabled,\n  mediaFileBaseUri: (state: State) => state.media.base_uri,\n  modules: (state: State) => state.modules,\n}\n"
  },
  {
    "path": "client/src/store/shows.store.ts",
    "content": "import { PodloveShow } from '../types/shows.types'\nimport { get } from 'lodash'\nimport { handleActions, createAction } from 'redux-actions'\n\nexport const INIT = 'podlove/publisher/shows/INIT'\nexport const SET = 'podlove/publisher/shows/SET'\nexport const SELECT = 'podlove/publisher/shows/SELECT'\n\nexport const init = createAction<void>(INIT)\nexport const set = createAction<PodloveShow[]>(SET)\nexport const select = createAction<string>(SELECT)\n\nexport type State = {\n  shows: PodloveShow[]\n}\n\nexport const initialState: State = {\n  shows: [],\n}\n\nexport const reducer = handleActions(\n  {\n    [SET]: (state: State, action: typeof set): State => ({\n      ...state,\n      shows: get(action, ['payload'], []) as PodloveShow[],\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  shows: (state: State) => state.shows,\n}\n"
  },
  {
    "path": "client/src/store/transcripts.store.ts",
    "content": "import { handleActions, createAction } from 'redux-actions'\n\nimport { PodloveTranscript, PodloveTranscriptVoice } from '../types/transcripts.types'\n\nexport const INIT = 'podlove/publisher/transcript/INIT'\nexport const SET_TRANSCRIPTS = 'podlove/publisher/transcript/SET_TRANSCRIPTS'\nexport const SET_VOICES = 'podlove/publisher/transcript/SET_VOICES'\nexport const UPDATE_VOICE = 'podlove/publisher/transcript/UPDATE_VOICE'\nexport const IMPORT_TRANSCRIPTS = 'podlove/publisher/transcript/IMPORT_TRANSCRIPTS'\nexport const IMPORT_ASSET_TRANSCRIPTS = 'podlove/publisher/transcript/IMPORT_ASSET_TRANSCRIPTS'\nexport const DELETE_TRANSCRIPTS = 'podlove/publisher/transcript/DELETE_TRANSCRIPTS'\n\nexport const init = createAction<void>(INIT)\nexport const setTranscripts = createAction<PodloveTranscript[]>(SET_TRANSCRIPTS)\nexport const setVoices = createAction<PodloveTranscriptVoice[]>(SET_VOICES)\nexport const updateVoice = createAction<{ voice: string; contributor: string }>(UPDATE_VOICE)\nexport const importTranscripts = createAction<string>(IMPORT_TRANSCRIPTS)\nexport const importTranscriptFromAsset = createAction<void>(IMPORT_ASSET_TRANSCRIPTS)\nexport const deleteTranscripts = createAction<void>(DELETE_TRANSCRIPTS)\n\nexport type State = {\n  transcripts: PodloveTranscript[]\n  voices: { voice: string, contributor: string }[]\n}\n\nexport const initialState: State = {\n  transcripts: [],\n  voices: [],\n}\n\nexport const reducer = handleActions(\n  {\n    [SET_TRANSCRIPTS]: (state: State, action: { payload: PodloveTranscript[] }): State => ({\n      ...state,\n      transcripts: action.payload,\n    }),\n    [SET_VOICES]: (state: State, action: { payload: PodloveTranscriptVoice[] }): State => ({\n      ...state,\n      voices: action.payload.map((elem: { voice: string; contributor_id: string }) => ({\n        voice: elem.voice,\n        contributor: elem.contributor_id,\n      })),\n    }),\n    [UPDATE_VOICE]: (state: State, action: { payload: { voice: string; contributor: string } }): State => ({\n      ...state,\n      voices: state.voices.map((voice) => {\n        if (voice.voice === action.payload.voice) {\n          return {\n            ...voice,\n            contributor: action.payload.contributor,\n          }\n        }\n\n        return voice\n      }),\n    }),\n  },\n  initialState\n)\n\nexport const selectors = {\n  transcripts: (state: State) => state.transcripts,\n  voices: (state: State) => state.voices,\n}\n"
  },
  {
    "path": "client/src/store/vue.ts",
    "content": "import type { Dispatch, Store, UnknownAction } from 'redux'\nimport { injectStore, mapState } from 'redux-vuex'\n\nimport type { State } from './index'\n\ntype AppSelector<T> = (state: State) => T\ntype AppSelectorMap = Record<string, AppSelector<unknown>>\n\ntype AppSelection<T extends AppSelectorMap> = {\n  [K in keyof T]: ReturnType<T[K]>\n}\n\nexport type AppStore = Store<State>\nexport type AppDispatch = Dispatch<UnknownAction>\n\nexport const injectAppStore = (): AppStore => injectStore() as AppStore\n\nexport const injectAppDispatch = (): AppDispatch => injectAppStore().dispatch as AppDispatch\n\nexport const mapAppState = <T extends AppSelectorMap>(selectors: T): AppSelection<T> =>\n  mapState(selectors as Record<string, (state: unknown) => unknown>) as AppSelection<T>\n"
  },
  {
    "path": "client/src/store/wordpress.store.ts",
    "content": "import { Action } from 'redux'\nimport { createAction } from 'redux-actions'\n\nexport const UPDATE = 'podlove/publisher/wordpress/UPDATE'\nexport const SELECT_MEDIA_FROM_LIBRARY = 'podlove/publisher/wordpress/SELECT_MEDIA_FROM_LIBRARY'\n\nexport const update = createAction<{ prop: string; value: any }>(UPDATE)\nexport const selectMediaFromLibrary = createAction<{ onSuccess: Action }>(SELECT_MEDIA_FROM_LIBRARY)\n"
  },
  {
    "path": "client/src/style.css",
    "content": "\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* fix overwrites */\ninput[type=checkbox]:checked::before {\n  content: '';\n}\n"
  },
  {
    "path": "client/src/types/chapters.types.ts",
    "content": "export interface PodloveChapter {\n  start: number;\n  title: string;\n  href?: string;\n  image?: string;\n}\n"
  },
  {
    "path": "client/src/types/contributors.types.ts",
    "content": "export interface PodloveContributor {\n  id: string\n  avatar: string\n  avatar_url: string\n  count: string\n  department: string\n  gender: string\n  jobtitle: string\n  mail: string\n  publicname: string\n  realname: string\n  nickname: string\n  organisation: string\n  slug: string\n}\n\nexport interface PodloveRole {\n  id: number\n  slug: string\n  title: string\n}\n\nexport interface PodloveGroup {\n  id: number\n  slug: string\n  title: string\n}\n"
  },
  {
    "path": "client/src/types/episode.types.ts",
    "content": "export interface PodloveEpisode {\n  slug: string\n  slug_frozen: boolean\n  number: string\n  title: string\n  subtitle: string\n  summary: string\n  poster: string\n}\n\nexport interface PodloveEpisodeContribution {\n  id: number\n  contributor_id: number\n  role_id: number\n  group_id: number\n  position: number\n  comment: string\n}\n"
  },
  {
    "path": "client/src/types/license.types.ts",
    "content": "export enum PodloveLicenseVersion {\n    cc0 = \"Public Domain License\",\n    pdmark = \"Public Domain Mark License\",\n    cc3 = \"Creative Commons 3.0 and earlier\",\n    cc4 = \"Creative Commons 4.0\"\n}\n\nexport enum PodloveLicenseOptionCommercial {\n    yes = \"Yes\",\n    no = \"No\"\n}\n\nexport enum PodloveLicenseOptionModification {\n    yes = \"Yes\",\n    yesbutshare = \"Yes, as long as others share alike\",\n    no = \"No\"\n}\n\nexport enum PodloveLicenseScope {\n    Episode = \"Episode\",\n    Podcast = \"Podcast\"\n}\n\nexport type PodloveJurisdicationObject = {\n    symbol: string,\n    name: string,\n    version: string\n}\n\nexport const PodloveLicenseOptionJurisdication: Array<PodloveJurisdicationObject> = [\n    { symbol: \"international\", name: \"International\"      , version: \"3.0\" },\n    { symbol: \"ar\",            name: \"Argentina\"          , version: \"2.5\" },\n    { symbol: \"au\",            name: \"Australia\"          , version: \"3.0\" },\n    { symbol: \"at\",            name: \"Austria\"            , version: \"3.0\" },\n    { symbol: \"be\",            name: \"Belgium\"            , version: \"2.0\" },\n    { symbol: \"br\",            name: \"Brazil\"             , version: \"3.0\" },\n    { symbol: \"bg\",            name: \"Bulgaria\"           , version: \"2.5\" },\n    { symbol: \"ca\",            name: \"Canada\"             , version: \"2.5\" },\n    { symbol: \"cl\",            name: \"Chile\"              , version: \"3.0\" },\n    { symbol: \"cn\",            name: \"China Mainland\"     , version: \"3.0\" },\n    { symbol: \"co\",            name: \"Colombia\"           , version: \"2.5\" },\n    { symbol: \"cr\",            name: \"Costa Rica\"         , version: \"3.0\" },\n    { symbol: \"hr\",            name: \"Croatia\"            , version: \"3.0\" },\n    { symbol: \"cz\",            name: \"Czech Republic\"     , version: \"3.0\" },\n    { symbol: \"dk\",            name: \"Denmark\"            , version: \"2.5\" },\n    { symbol: \"ec\",            name: \"Ecuador\"            , version: \"3.0\" },\n    { symbol: \"eg\",            name: \"Egypt\"              , version: \"3.0\" },\n    { symbol: \"ee\",            name: \"Estonia\"            , version: \"3.0\" },\n    { symbol: \"fi\",            name: \"Finland\"            , version: \"1.0\" },\n    { symbol: \"fr\",            name: \"France\"             , version: \"3.0\" },\n    { symbol: \"de\",            name: \"Germany\"            , version: \"3.0\" },\n    { symbol: \"gr\",            name: \"Greece\"             , version: \"3.0\" },\n    { symbol: \"gt\",            name: \"Guatemala\"          , version: \"3.0\" },\n    { symbol: \"hk\",            name: \"Hong Kong\"          , version: \"3.0\" },\n    { symbol: \"hu\",            name: \"Hungary\"            , version: \"2.5\" },\n    { symbol: \"igo\",           name: \"IGO\"                , version: \"3.0\" },\n    { symbol: \"in\",            name: \"India\"              , version: \"2.5\" },\n    { symbol: \"ie\",            name: \"Ireland\"            , version: \"3.0\" },\n    { symbol: \"il\",            name: \"Israel\"             , version: \"2.5\" },\n    { symbol: \"it\",            name: \"Italy\"              , version: \"3.0\" },\n    { symbol: \"jp\",            name: \"Japan\"              , version: \"2.1\" },\n    { symbol: \"lu\",            name: \"Luxembourg\"         , version: \"3.0\" },\n    { symbol: \"mk\",            name: \"Macedonia\"          , version: \"2.5\" },\n    { symbol: \"my\",            name: \"Malaysia\"           , version: \"2.5\" },\n    { symbol: \"mt\",            name: \"Malta\"              , version: \"2.5\" },\n    { symbol: \"mx\",            name: \"Mexico\"             , version: \"2.5\" },\n    { symbol: \"nl\",            name: \"Netherlands\"        , version: \"3.0\" },\n    { symbol: \"nz\",            name: \"New Zealand\"        , version: \"3.0\" },\n    { symbol: \"no\",            name: \"Norway\"             , version: \"3.0\" },\n    { symbol: \"pe\",            name: \"Peru\"               , version: \"2.5\" },\n    { symbol: \"ph\",            name: \"Philippines\"        , version: \"3.0\" },\n    { symbol: \"pl\",            name: \"Poland\"             , version: \"3.0\" },\n    { symbol: \"pt\",            name: \"Portugal\"           , version: \"3.0\" },\n    { symbol: \"pr\",            name: \"Puerto Rico\"        , version: \"3.0\" },\n    { symbol: \"ro\",            name: \"Romania\"            , version: \"3.0\" },\n    { symbol: \"rs\",            name: \"Serbia\"             , version: \"3.0\" },\n    { symbol: \"sg\",            name: \"Singapore\"          , version: \"3.0\" },\n    { symbol: \"si\",            name: \"Slovenia\"           , version: \"2.5\" },\n    { symbol: \"za\",            name: \"South Africa\"       , version: \"2.5\" },\n    { symbol: \"kp\",            name: \"South Korea\"        , version: \"2.0\" },\n    { symbol: \"es\",            name: \"Spain\"              , version: \"3.0\" },\n    { symbol: \"se\",            name: \"Sweden\"             , version: \"2.5\" },\n    { symbol: \"ch\",            name: \"Switzerland\"        , version: \"3.0\" },\n    { symbol: \"tw\",            name: \"Taiwan\"             , version: \"3.0\" },\n    { symbol: \"th\",            name: \"Thailand\"           , version: \"3.0\" },\n    { symbol: \"gb\",            name: \"UK: England & Wales\", version: \"2.0\" },\n    { symbol: \"gb_sc\",         name: \"UK: Scotland\"       , version: \"2.5\" },\n    { symbol: \"ug\",            name: \"Uganda\"             , version: \"3.0\" },\n    { symbol: \"us\",            name: \"United States\"      , version: \"3.0\" },\n    { symbol: \"vn\",            name: \"Vietnam\"            , version: \"3.0\" },\n]\n\nexport interface PodloveLicense {\n    type: string | null,\n    version: PodloveLicenseVersion | null,\n    optionCommercial: PodloveLicenseOptionCommercial | null,\n    optionModification: PodloveLicenseOptionModification | null,\n    optionJurisdication: PodloveJurisdicationObject | null,\n}"
  },
  {
    "path": "client/src/types/relatedEpisodes.types.ts",
    "content": "export interface PodloveEpisodeList {\n    episode_id: number,\n    episode_title: string,\n}\n"
  },
  {
    "path": "client/src/types/shows.types.ts",
    "content": "export interface PodloveShow {\n  id: number\n  title: string\n  slug: string\n  subtitle: string\n  summary: string\n  image: string\n  language: string\n  category: string\n  auphonic_preset: string\n}\n"
  },
  {
    "path": "client/src/types/transcripts.types.ts",
    "content": "export interface PodloveTranscript {\n  voice: string;\n  start: string,\n  start_ms: number;\n  end: string;\n  end_ms: number;\n  text: string;\n}\n\nexport interface PodloveTranscriptVoice {\n  voice: string,\n  contributor_id: string\n}\n"
  },
  {
    "path": "client/src/vue-shims.d.ts",
    "content": "declare module '*.vue' {\n  import type { DefineComponent } from 'vue'\n  const component: DefineComponent<{}, {}, any>\n  export default component\n}\n"
  },
  {
    "path": "client/tailwind.config.js",
    "content": "// tailwind.config.js\nconst defaultTheme = require('tailwindcss/defaultTheme')\n\nmodule.exports = {\n  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],\n  theme: {\n    extend: {\n      fontFamily: {\n        sans: ['Inter var', ...defaultTheme.fontFamily.sans],\n      },\n    },\n  },\n  plugins: [require('@tailwindcss/forms')],\n}\n"
  },
  {
    "path": "client/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"target\": \"esnext\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"strict\": true,\n    \"jsx\": \"preserve\",\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"types\": [],\n    \"lib\": [\"esnext\", \"dom\"],\n    \"paths\": {\n      \"@components/*\": [\"src/components/*\"],\n      \"@store\": [\"src/store/index.ts\"],\n      \"@store/*\": [\"src/store/*\"],\n      \"@types/*\": [\"src/types/*\"],\n      \"@sagas/*\": [\"src/sagas/*\"],\n      \"@lib/*\": [\"src/lib/*\"],\n    }\n  },\n  \"exclude\": [\"node_modules/**/*\"],\n  \"include\": [\"src/**/*.ts\", \"src/**/*.d.ts\", \"src/**/*.tsx\", \"src/**/*.vue\", \"typings/*.d.ts\", \"js/src/**/*.ts\", \"js/src/**/*.d.ts\", \"js/src/**/*.tsx\", \"js/src/**/*.vue\"]\n}\n"
  },
  {
    "path": "client/typings/podlove.d.ts",
    "content": "interface Chapter {\n  start: number;\n  title: string;\n  url?: string;\n  image?: string;\n}\n\ndeclare module '@podlove/utils/keyboard' {\n  export module utils {\n    export function keydown(callback: Function): void;\n    export function keyup(callback: Function): void;\n  }\n}\n\ndeclare module 'podcast-chapter-parser-mp4chaps' {\n  export function parse(text: string): Chapter[];\n}\n\ndeclare module 'podcast-chapter-parser-audacity' {\n  export function parse(text: string): Chapter[];\n}\n\ndeclare module 'podcast-chapter-parser-hindenburg' {\n  export function parser(parser: any): {\n    parse(text: string): Chapter[];\n  }\n}\n\ndeclare module 'podcast-chapter-parser-psc' {\n  export function parser(parser: any): {\n    parse(text: string): Chapter[];\n  }\n}\n\n\n\n\n\n"
  },
  {
    "path": "client/typings/redux-actions.d.ts",
    "content": "declare module 'redux-actions' {\n  export const handleActions: (bindings: any, state: any) => any;\n  export const createAction: <T>(type: string) => (payload: T) => { type: string, payload: T };\n}\n"
  },
  {
    "path": "client/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport * as path from 'path'\nimport vue from '@vitejs/plugin-vue'\n\nconst root = path.resolve(__dirname)\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [vue()],\n  server: {\n    proxy: {\n      '/wp-json': {\n        target: process.env.WORDPRESS_URL || 'http://podlove.local',\n        changeOrigin: true,\n        secure: false,\n\n      }\n    }\n  },\n  root: path.resolve(__dirname),\n  resolve: {\n    alias: {\n      vue: 'vue/dist/vue.esm-bundler.js',\n      '@store': path.resolve(root, 'src', 'store'),\n      '@components': path.resolve(root, 'src', 'components'),\n      '@types': path.resolve(root, 'src', 'types'),\n      '@sagas': path.resolve(root, 'src', 'sagas'),\n      '@lib': path.resolve(root, 'src', 'lib'),\n    }\n  },\n  build: {\n    outDir: path.resolve(root, 'dist'),\n    cssCodeSplit: false,\n    rollupOptions: {\n      output: {\n        entryFileNames: `client.js`,\n        chunkFileNames: `chunk-[name].js`,\n        assetFileNames: `[name].[ext]`\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "composer.json",
    "content": "{\n  \"name\": \"podlove/podcast-publisher\",\n  \"description\": \"Podcast Publishing Plugin for WordPress\",\n  \"license\": \"MIT\",\n  \"require\": {\n    \"php\": \"^8.0\",\n    \"podlove/podlove-timeline\": \"2.*\",\n    \"monolog/monolog\": \"2.9.*\",\n    \"symfony/yaml\": \"6.0.*\",\n    \"symfony/polyfill-mbstring\": \"1.27.*\",\n    \"twig/twig\": \"3.14.*\",\n    \"geoip2/geoip2\": \"~2.0\",\n    \"matomo/device-detector\": \"6.1.*\",\n    \"podlove/webvtt-parser\": \"^1.1.6\",\n    \"geertw/ip-anonymizer\": \"^1.1\",\n    \"dariuszp/cli-progress-bar\": \"^1.0\",\n    \"league/csv\": \"9.8.0\",\n    \"gajus/dindent\": \"^2.0\",\n    \"ramsey/uuid\": \"^4.7\",\n    \"symfony/deprecation-contracts\": \"^3.0\"\n  },\n  \"require-dev\": {\n    \"pear/pear_exception\": \"1.0.*@dev\",\n    \"bamarni/composer-bin-plugin\": \"1.4.1\",\n    \"phpunit/phpunit\": \"^9.6\",\n    \"yoast/phpunit-polyfills\": \"^4.0\",\n    \"php-stubs/wordpress-stubs\": \"^6.9\",\n    \"php-stubs/wordpress-tests-stubs\": \"^6.9\"\n  },\n  \"autoload\": {\n    \"classmap\": [\n      \"lib/\",\n      \"includes/\",\n      \"vendor-prefixed/\"\n    ],\n    \"exclude-from-classmap\": [\n      \"/vendor/twig\"\n    ]\n  },\n  \"config\": {\n    \"platform\": {\n      \"php\": \"8.0.29\"\n    },\n    \"allow-plugins\": {\n      \"bamarni/composer-bin-plugin\": true\n    }\n  },\n  \"scripts\": {\n    \"prefix-dependencies\": [\n      \"composer prefix-twig\",\n      \"composer prefix-matomo\",\n      \"composer prefix-monolog\",\n      \"composer prefix-psr\"\n    ],\n    \"prefix-twig\": [\n      \"@php ./vendor-bin/php-scoper/vendor/humbug/php-scoper/bin/php-scoper add-prefix --prefix=PodlovePublisher_Vendor --output-dir=./vendor-prefixed/twig --config=config/php-scoper/twig.inc.php --force\"\n    ],\n    \"prefix-matomo\": [\n      \"@php ./vendor-bin/php-scoper/vendor/humbug/php-scoper/bin/php-scoper add-prefix --prefix=PodlovePublisher_Vendor --output-dir=./vendor-prefixed/matomo --config=config/php-scoper/matomo.inc.php --force\"\n    ],\n    \"prefix-monolog\": [\n      \"@php ./vendor-bin/php-scoper/vendor/humbug/php-scoper/bin/php-scoper add-prefix --prefix=PodlovePublisher_Vendor --output-dir=./vendor-prefixed/monolog --config=config/php-scoper/monolog.inc.php --force\"\n    ],\n    \"prefix-psr\": [\n      \"@php ./vendor-bin/php-scoper/vendor/humbug/php-scoper/bin/php-scoper add-prefix --prefix=PodlovePublisher_Vendor --output-dir=./vendor-prefixed/psr --config=config/php-scoper/psr.inc.php --force\"\n    ]\n  }\n}\n"
  },
  {
    "path": "config/php-scoper/matomo.inc.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Isolated\\Symfony\\Component\\Finder\\Finder;\n\nreturn [\n    'finders' => [\n        Finder::create()->files()->in('vendor/matomo/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']),\n        Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']),\n    ],\n    'patchers' => [\n        function (string $filePath, string $prefix, string $content): string {\n            $content = str_replace(\n                'class_exists(\\'DeviceDetector',\n                'class_exists(\\''.$prefix.'\\\\\\DeviceDetector',\n                $content\n            );\n\n            $content = str_replace(\n                '$className = \\'DeviceDetector',\n                '$className = \\''.$prefix.'\\\\\\DeviceDetector',\n                $content\n            );\n\n            // hack: remove faulty escaping in regex; not sure why php-scoper even touch this line\n            if (stristr($filePath, 'AbstractParser.php') || stristr($filePath, 'DeviceDetector.php') || stristr($filePath, 'ShellTv.php') || stristr($filePath, 'HbbTV.php') || stristr($filePath, 'Version.php')) {\n                $content = str_replace('\\\\\\\\', '\\\\', $content);\n            }\n\n            return $content.'';\n        }\n    ]\n];\n"
  },
  {
    "path": "config/php-scoper/monolog.inc.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Isolated\\Symfony\\Component\\Finder\\Finder;\n\nreturn [\n    'finders' => [\n        Finder::create()->files()->in('vendor/monolog/*')->name(['*.php', 'LICENSE', 'composer.json']),\n    ],\n    'patchers' => [\n    ]\n];\n"
  },
  {
    "path": "config/php-scoper/piwik.inc.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Isolated\\Symfony\\Component\\Finder\\Finder;\n\nreturn [\n    'finders' => [\n        Finder::create()->files()->in('vendor/piwik/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']),\n        Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']),\n    ],\n    'patchers' => [\n        function (string $filePath, string $prefix, string $content): string {\n            $content = str_replace(\n                'class_exists(\\'DeviceDetector',\n                'class_exists(\\''.$prefix.'\\\\\\DeviceDetector',\n                $content\n            );\n\n            $content = str_replace(\n                '$className = \\'DeviceDetector',\n                '$className = \\''.$prefix.'\\\\\\DeviceDetector',\n                $content\n            );\n\n            return $content.'';\n        }\n    ]\n];\n"
  },
  {
    "path": "config/php-scoper/psr.inc.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Isolated\\Symfony\\Component\\Finder\\Finder;\n\nreturn [\n    'finders' => [\n        Finder::create()->files()->in('vendor/psr/*')->name(['*.php', 'LICENSE', 'composer.json']),\n    ],\n    'patchers' => [\n    ]\n];\n"
  },
  {
    "path": "config/php-scoper/twig.inc.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Isolated\\Symfony\\Component\\Finder\\Finder;\n\nreturn [\n    // By default when running php-scoper add-prefix, it will prefix all relevant code found in the current working\n    // directory. You can however define which files should be scoped by defining a collection of Finders in the\n    // following configuration key.\n    //\n    // For more see: https://github.com/humbug/php-scoper#finders-and-paths\n    'finders' => [\n        Finder::create()->files()->in('vendor/twig/*')->name(['*.php', 'LICENSE', 'composer.json']),\n    ],\n\n    'patchers' => [\n        function (string $filePath, string $prefix, string $content): string {\n            // suppress warnings for class_alias\n            $content = preg_replace('/(\\\\\\class_alias)/', '@${1}', $content);\n\n            if (stristr($filePath, 'CoreExtension.php') || stristr($filePath, 'EscaperExtension.php') || stristr($filePath, 'DebugExtension.php')) {\n                $pattern = '/TwigFilter\\((\\'[^\\']+\\'),\\s+\\'(_?twig[^\\']+)\\'/';\n                $content = preg_replace_callback(\n                    $pattern,\n                    function ($matches) use ($prefix) {\n                        return 'TwigFilter('.$matches[1].', \\''.$prefix.'\\\\'.$matches[2].'\\'';\n                    },\n                    $content\n                );\n\n                $pattern = '/TwigFunction\\((\\'[^\\']+\\'),\\s+\\'(twig[^\\']+)\\'/';\n                $content = preg_replace_callback(\n                    $pattern,\n                    function ($matches) use ($prefix) {\n                        return 'TwigFunction('.$matches[1].', \\''.$prefix.'\\\\'.$matches[2].'\\'';\n                    },\n                    $content\n                );\n\n                $pattern = '/TwigTest\\((\\'[^\\']+\\'),\\s+\\'(twig[^\\']+)\\'/';\n                $content = preg_replace_callback(\n                    $pattern,\n                    function ($matches) use ($prefix) {\n                        return 'TwigTest('.$matches[1].', \\''.$prefix.'\\\\'.$matches[2].'\\'';\n                    },\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'ForNode.php')) {\n                $content = str_replace(\n                    ' = twig_ensure_traversable',\n                    ' = '.$prefix.'\\\\\\twig_ensure_traversable',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'CaptureNode.php')) {\n                $content = str_replace(\n                    '\\\\\\Twig',\n                    $prefix.'\\\\\\Twig',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'IncludeNode.php') || stristr($filePath, 'WithNode.php')) {\n                $content = str_replace(\n                    'twig_array_merge(',\n                    $prefix.'\\\\\\twig_array_merge(',\n                    $content\n                );\n                $content = str_replace(\n                    'twig_to_array(',\n                    $prefix.'\\\\\\twig_to_array(',\n                    $content\n                );\n                $content = str_replace(\n                    'twig_test_iterable(',\n                    $prefix.'\\\\\\twig_test_iterable(',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'InBinary.php')) {\n                $content = str_replace(\n                    'twig_in_filter(',\n                    $prefix.'\\\\\\twig_in_filter(',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'MethodCallExpression.php')) {\n                $content = str_replace(\n                    'twig_call_macro(',\n                    $prefix.'\\\\\\twig_call_macro(',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'ModuleNode.php')) {\n                $content = str_replace(\n                    'use Twig\\\\',\n                    'use '.$prefix.'\\\\\\Twig\\\\',\n                    $content\n                );\n            }\n\n            if (stristr($filePath, 'GetAttrExpression.php')) {\n                $content = str_replace(\n                    'twig_get_attribute',\n                    $prefix.'\\\\\\twig_get_attribute',\n                    $content\n                );\n            }\n\n            return $content;\n        },\n    ],\n];\n"
  },
  {
    "path": "css/about.css",
    "content": "/*------------------------------------------------------------------------------\n  22.0 - About Pages\n\n   1.0 Global: About, Credits, Freedoms\n    1.1 Typography\n    1.2 Structure\n    1.3 Point Releases\n   2.0 About Page\n    2.1 Typography\n    2.2 Structure\n------------------------------------------------------------------------------*/\n\n/*------------------------------------------------------------------------------\n  1.0 - Global: About, Credits, Freedoms\n------------------------------------------------------------------------------*/\n\n.podlove-about-wrap {\n\tposition: relative;\n\tmargin: 25px 40px 0 20px;\n\tmax-width: 1050px; /* readability */\n\tfont-size: 15px;\n}\n\n.podlove-about-wrap div.updated,\n.podlove-about-wrap div.error {\n\tdisplay: none !important;\n}\n\n.podlove-about-wrap hr {\n\tborder: 0;\n\theight: 0;\n\tmargin: 0;\n\tborder-top: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.podlove-about-wrap img {\n\tmargin: 0;\n\tmax-width: 100%;\n\theight: auto;\n\tvertical-align: middle;\n}\n\n/* WordPress Version Badge */\n\n\n\n.podlove-badge {\n\tbackground: #777 url(\"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjQsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCIgWw0KCTwhRU5USVRZIG5zX2V4dGVuZCAiaHR0cDovL25zLmFkb2JlLmNvbS9FeHRlbnNpYmlsaXR5LzEuMC8iPg0KCTwhRU5USVRZIG5zX2FpICJodHRwOi8vbnMuYWRvYmUuY29tL0Fkb2JlSWxsdXN0cmF0b3IvMTAuMC8iPg0KCTwhRU5USVRZIG5zX2dyYXBocyAiaHR0cDovL25zLmFkb2JlLmNvbS9HcmFwaHMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfdmFycyAiaHR0cDovL25zLmFkb2JlLmNvbS9WYXJpYWJsZXMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfaW1yZXAgImh0dHA6Ly9ucy5hZG9iZS5jb20vSW1hZ2VSZXBsYWNlbWVudC8xLjAvIj4NCgk8IUVOVElUWSBuc19zZncgImh0dHA6Ly9ucy5hZG9iZS5jb20vU2F2ZUZvcldlYi8xLjAvIj4NCgk8IUVOVElUWSBuc19jdXN0b20gImh0dHA6Ly9ucy5hZG9iZS5jb20vR2VuZXJpY0N1c3RvbU5hbWVzcGFjZS8xLjAvIj4NCgk8IUVOVElUWSBuc19hZG9iZV94cGF0aCAiaHR0cDovL25zLmFkb2JlLmNvbS9YUGF0aC8xLjAvIj4NCl0+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zOng9IiZuc19leHRlbmQ7IiB4bWxuczppPSImbnNfYWk7IiB4bWxuczpncmFwaD0iJm5zX2dyYXBoczsiDQoJIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiDQoJIHZpZXdCb3g9IjAgMCAxMjggMTI4IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxtZXRhZGF0YT4NCgk8c2Z3ICB4bWxucz0iJm5zX3NmdzsiPg0KCQk8c2xpY2VzPjwvc2xpY2VzPg0KCQk8c2xpY2VTb3VyY2VCb3VuZHMgIGhlaWdodD0iMTI3Ljk4MyIgd2lkdGg9IjcyLjQyNCIgYm90dG9tTGVmdE9yaWdpbj0idHJ1ZSIgeD0iMjcuMzk2IiB5PSIwLjUwNSI+PC9zbGljZVNvdXJjZUJvdW5kcz4NCgk8L3Nmdz4NCjwvbWV0YWRhdGE+DQo8cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTIuMjczLDEyNy45OTVIMzUuOTQzYy00LjQ0NCwwLTguMDQ3LTMuNTgxLTguMDQ3LTcuOTk5VjguMDExYzAtNC40MTcsMy42MDMtNy45OTksOC4wNDctNy45OTloNTYuMzMxDQoJYzQuNDQzLDAsOC4wNDcsMy41ODIsOC4wNDcsNy45OTl2MTExLjk4NUMxMDAuMzIsMTI0LjQxNCw5Ni43MTgsMTI3Ljk5NSw5Mi4yNzMsMTI3Ljk5NXogTTYzLjYwNSwxMTEuOTk2DQoJYzEzLjMzMywwLDI0LjE0MS0xMC43NDMsMjQuMTQxLTIzLjk5N2MwLTEzLjI1MS0xMC44MDktMjMuOTk1LTI0LjE0MS0yMy45OTVjLTEzLjMzMywwLTI0LjE0MSwxMC43NDQtMjQuMTQxLDIzLjk5NQ0KCUMzOS40NjQsMTAxLjI1Myw1MC4yNzMsMTExLjk5Niw2My42MDUsMTExLjk5NnogTTkyLjI3Myw4LjAxMUgzNS45NDN2NDcuOTkzaDU2LjMzMVY4LjAxMUw5Mi4yNzMsOC4wMTF6IE02My42MDUsNzkuMjQ2DQoJYzQuODY0LDAsOC44MDYsMy45Miw4LjgwNiw4Ljc1M2MwLDQuODM2LTMuOTQsOC43NTUtOC44MDYsOC43NTVjLTQuODY0LDAtOC44MDctMy45MTktOC44MDctOC43NTUNCglDNTQuNzk5LDgzLjE2Niw1OC43NDIsNzkuMjQ2LDYzLjYwNSw3OS4yNDZ6Ii8+DQo8cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjMuOTkyLDIyLjk3MmM1LjAzMy0xMS4yNSwyMC4yOTktOS4wOTgsMjAuMzk4LDQuNTM0YzAuMDU3LDcuODA5LTIwLjM2OSwyMS44NzEtMjAuMzY5LDIxLjg3MQ0KCXMtMjAuNDctMTMuOTI5LTIwLjQxMy0yMS43ODlDNDMuNzA4LDEzLjk4OCw1OC43MTIsMTEuMjUzLDYzLjk5MiwyMi45NzJ6Ii8+DQo8L3N2Zz4NCg==\");\n\tbackground-position: center 24px;\n\tbackground-repeat: no-repeat;\n\t-webkit-background-size: 85px 85px;\n\tbackground-size: 85px 85px;\n\tfont-size: 14px;\n\ttext-align: center;\n\tfont-weight: 600;\n\tmargin: 5px 0 0;\n\tpadding-top: 110px;\n\theight: 30px;\n\tdisplay: inline-block;\n\twidth: 140px;\n}\n\n.podlove-about-wrap .podlove-badge {\n\tposition: absolute;\n\ttop: 0;\n\tright: 0;\n}\n\n/* Tabs */\n\n.podlove-about-wrap h2.nav-tab-wrapper {\n\tpadding-left: 6px;\n}\n\n.podlove-about-wrap h2 .nav-tab {\n\tpadding: 4px 15px 6px;\n\tmargin: 0 3px -1px 0;\n\tfont-size: 18px;\n\tvertical-align: top;\n\tborder-width: 1px;\n\twhite-space: nowrap;\n}\n\n/* 1.1 - Typography */\n\n.podlove-about-wrap p {\n\tline-height: 1.6em;\n\tfont-size: 14px;\n}\n\n.podlove-about-wrap h1 {\n\tmargin: 0.2em 200px 0 0;\n\tcolor: #333;\n\tline-height: 1.2em;\n\tfont-size: 2.8em;\n\tfont-weight: 400;\n}\n\n.podlove-about-wrap h3 {\n\tmargin: 2em 0 .6em;\n\tfont-size: 1.25em;\n\tline-height: 1.5em;\n}\n\n.podlove-about-wrap h4 {\n\tcolor: #222;\n}\n\n.podlove-about-wrap code,\n.podlove-about-wrap ol li p {\n\tfont-size: 14px;\n}\n\n.podlove-about-wrap .about-description,\n.podlove-about-wrap .about-text {\n\tmargin-top: 1.4em;\n\tfont-weight: normal;\n\tline-height: 1.6em;\n\tfont-size: 19px;\n}\n\n.podlove-about-wrap .about-text {\n\tmargin: 1em 200px 1em 0;\n\tmin-height: 60px;\n\tcolor: #777;\n}\n\n/* 1.2 - Structure */\n\n.podlove-about-wrap .two-col > div {\n\tposition: relative;\n\twidth: 47.6%;\n\tmargin-right: 4.799999999%;\n\tfloat: left;\n}\n\n.podlove-about-wrap .three-col > div {\n\tposition: relative;\n\twidth: 29.95%;\n\tmargin-right: 4.999999999%;\n\tfloat: left;\n}\n\n.podlove-about-wrap .col .last-feature {\n\tmargin-right: 0;\n}\n\n/* 1.3 - Point Releases */\n\n.podlove-about-wrap .point-releases {\n\tmargin-top: 5px;\n\tborder-bottom: 1px solid #dfdfdf;\n}\n\n.podlove-about-wrap .changelog.point-releases h3 {\n\tpadding-top: 35px;\n}\n\n.podlove-about-wrap .changelog.point-releases h3:first-child {\n\tpadding-top: 7px;\n}\n\n/*------------------------------------------------------------------------------\n  2.0 - About Page\n------------------------------------------------------------------------------*/\n\n/* 2.1 - Typography */\n\n.podlove-about-wrap .headline-feature h2 {\n\tmargin: 1.1em 0 0.2em;\n\tfont-size: 2.4em;\n\tfont-weight: 300;\n\tline-height: 1.3;\n\ttext-align: center;\n}\n\n.podlove-about-wrap .feature-list h2 {\n\tmargin: 30px 0 15px;\n\ttext-align: center;\n}\n\n.podlove-about-wrap .feature-section h4 {\n\tmargin: 1.4em 0 0.6em 0;\n\tfont-size: 1.2em;\n}\n\n.podlove-about-wrap .feature-section p {\n\tmargin-top: 0.6em;\n}\n\n/* 2.2 - Structure */\n\n.podlove-about-wrap .featured-image {\n\ttext-align: center;\n}\n\n.podlove-about-wrap .feature-section {\n\toverflow: hidden;\n\tpadding-bottom: 20px;\n}\n\n.podlove-about-wrap .headline-feature .feature-section {\n\tmargin: 0 auto;\n\tmax-width: 82%;\n}\n\n.podlove-about-wrap .headline-feature .feature-section .col:first-child {\n\tfloat: left;\n\tmargin: 15px 5% 0 0;\n\twidth: 55%;\n}\n\n.podlove-about-wrap .headline-feature .feature-section .col:last-child {\n\tfloat: right;\n\tmargin: 15px 0 40px;\n\twidth: 40%;\n}\n\n.podlove-about-wrap .feature-list .feature-section {\n\tmargin-top: 0;\n}\n\n.top-feature h3 {\n\ttext-align: center;\n}\n\n.top-feature p, .top-feature ul {\n\tmax-width: 68%;\n\tmargin: 0 auto 20px;\n}\n\n/* Return to Dashboard Home link */\n\n.podlove-about-wrap .return-to-dashboard {\n\tmargin: 30px 0 0 -5px;\n\tfont-size: 14px;\n\tfont-weight: bold;\n}\n\n.podlove-about-wrap .return-to-dashboard a {\n\ttext-decoration: none;\n\tpadding: 0 5px;\n}\n\n/* SVGs */\n.podlove-about-wrap .feature-list svg {\n\tfloat: left;\n\tclear: left;\n\tmargin: 15px 15px 0 0 ;\n\theight: 90px;\n\twidth: 90px;\n\tbackground-color: #cccccc;\n\t-webkit-border-radius: 50%;\n\tborder-radius: 50%;\n\tfill: #999;\n\tborder: 1px solid #c1c1c1;\n}\n\n.podlove-about-wrap .feature-list.finer-points h4,\n.podlove-about-wrap .feature-list.finer-points p {\n\tmargin-left: 115px;\n}\n\n/*------------------------------------------------------------------------------\n  4.0 - Media Queries\n------------------------------------------------------------------------------*/\n\n@media screen and ( max-width: 782px ) {\n\t.podlove-about-wrap .one-col > div,\n\t.podlove-about-wrap .two-col > div,\n\t.podlove-about-wrap .three-col > div {\n\t\twidth: 100%;\n\t\tmargin: 0 0 40px;\n\t\tpadding: 0 0 40px;\n\t\tborder-bottom: 1px solid rgba(0, 0, 0, 0.1);\n\t}\n\n\t.podlove-about-wrap .feature-list div,\n\t.podlove-about-wrap .col > div.last-feature {\n\t\tmargin: 0;\n\t\tpadding: 0;\n\t\tborder-bottom: none;\n\t}\n\n\t.podlove-about-wrap .headline-feature .feature-section {\n\t\tmax-width: 100%;\n\t}\n\n\t.podlove-about-wrap .feature-list .feature-section {\n\t\tpadding: 0 0 40px;\n\t}\n}\n\n@media only screen and (max-width: 500px) {\n\t.podlove-about-wrap {\n\t\tmargin-right: 20px;\n\t\tmargin-left: 10px;\n\t}\n\n\t.podlove-about-wrap h1,\n\t.podlove-about-wrap .about-text {\n\t\tmargin-right: 0;\n\t}\n\n\t.podlove-about-wrap .about-text {\n\t\tmargin-bottom: 0.25em;\n\t}\n\n\t.podlove-about-wrap .podlove-badge {\n\t\tposition: relative;\n\t\tmargin-bottom: 1.5em;\n\t\twidth: 100%;\n\t}\n\n\t.podlove-about-wrap h2.nav-tab-wrapper {\n\t\tpadding-left: 0;\n\t\tborder-bottom: 0;\n\t}\n\n\t.podlove-about-wrap h2 .nav-tab {\n\t\tmargin-top: 10px;\n\t\tmargin-right: 10px;\n\t\tborder-bottom: 1px solid #ccc;\n\t}\n\n\t.podlove-about-wrap .three-col div,\n\t.podlove-about-wrap .headline-feature .feature-section div {\n\t\twidth: 100% !important;\n\t\tfloat: none !important;\n\t}\n}\n\n@media only screen and (max-width: 400px) {\n\t.podlove-about-wrap .feature-list svg {\n\t\tmargin-top: 15px;\n\t\theight: 65px;\n\t\twidth: 65px;\n\t}\n\t.podlove-about-wrap .feature-list.finer-points h4,\n\t.podlove-about-wrap .feature-list.finer-points p {\n\t\tmargin-left: 80px;\n\t}\n}\n"
  },
  {
    "path": "css/admin-font.css",
    "content": "@font-face {\n\tfont-family: 'Podlove';\n\tsrc:url('../fonts/Podlove.eot');\n\tsrc:url('../fonts/Podlove.eot?#iefix') format('embedded-opentype'),\n\t\turl('../fonts/Podlove.woff') format('woff'),\n\t\turl('../fonts/Podlove.ttf') format('truetype'),\n\t\turl('../fonts/Podlove.svg#Podlove') format('svg');\n\tfont-weight: normal;\n\tfont-style: normal;\n}\n\n/* Use the following CSS code if you want to use data attributes for inserting your icons */\n[data-icon]:before {\n\tfont-family: 'Podlove';\n\tcontent: attr(data-icon);\n\tspeak: none;\n\tfont-weight: normal;\n\tfont-variant: normal;\n\ttext-transform: none;\n\tline-height: 1;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\n/* Use the following CSS code if you want to have a class per icon */\n/*\nInstead of a list of all class selectors,\nyou can use the generic selector below, but it's slower:\n[class*=\"podlove-icon-\"] {\n*/\n.podlove-icon-reorder, .podlove-icon-ok, .podlove-icon-remove, .podlove-icon-minus, .podlove-icon-spinner, .podlove-icon-reply, .podlove-icon-share, .podlove-icon-time, .podlove-icon-repeat, .podlove-icon-plus, .podlove-icon-cloud-download, .podlove-icon-external-link, .podlove-icon-circle, .podlove-icon-cogs, .podlove-icon-ban-circle, .podlove-icon-heart, .podlove-icon-appdotnet, .podlove-icon-github, .podlove-icon-twitter, .podlove-icon-facebook, .podlove-icon-googleplus, .podlove-icon-pinterest, .podlove-icon-flattr, .podlove-icon-paypal, .podlove-icon-house, .podlove-icon-mail, .podlove-icon-cart, .podlove-icon-edit, .podlove-icon-calendar {\n\tfont-family: 'Podlove';\n\tspeak: none;\n\tfont-style: normal;\n\tfont-weight: normal;\n\tfont-variant: normal;\n\ttext-transform: none;\n\tline-height: 1;\n\t-webkit-font-smoothing: antialiased;\n}\n.podlove-icon-reorder:before {\n\tcontent: \"\\f0c9\";\n}\n.podlove-icon-ok:before {\n\tcontent: \"\\f00c\";\n}\n.podlove-icon-remove:before {\n\tcontent: \"\\f00d\";\n}\n.podlove-icon-minus:before {\n\tcontent: \"\\f068\";\n}\n.podlove-icon-spinner:before {\n\tcontent: \"\\f110\";\n}\n.podlove-icon-reply:before {\n\tcontent: \"\\f112\";\n}\n.podlove-icon-share:before {\n\tcontent: \"\\f045\";\n}\n.podlove-icon-time:before {\n\tcontent: \"\\f017\";\n}\n.podlove-icon-repeat:before {\n\tcontent: \"\\f01e\";\n}\n.podlove-icon-plus:before {\n\tcontent: \"\\f067\";\n}\n.podlove-icon-cloud-download:before {\n\tcontent: \"\\f0ed\";\n}\n.podlove-icon-external-link:before {\n\tcontent: \"\\f08e\";\n}\n.podlove-icon-circle:before {\n\tcontent: \"\\f111\";\n}\n.podlove-icon-cogs:before {\n\tcontent: \"\\f085\";\n}\n.podlove-icon-ban-circle:before {\n\tcontent: \"\\f05e\";\n}\n.podlove-icon-heart:before {\n\tcontent: \"\\f004\";\n}\n.podlove-icon-appdotnet:before {\n\tcontent: \"\\e000\";\n}\n.podlove-icon-github:before {\n\tcontent: \"\\e001\";\n}\n.podlove-icon-twitter:before {\n\tcontent: \"\\e002\";\n}\n.podlove-icon-facebook:before {\n\tcontent: \"\\e003\";\n}\n.podlove-icon-googleplus:before {\n\tcontent: \"\\e004\";\n}\n.podlove-icon-pinterest:before {\n\tcontent: \"\\e005\";\n}\n.podlove-icon-flattr:before {\n\tcontent: \"\\e006\";\n}\n.podlove-icon-paypal:before {\n\tcontent: \"P\";\n}\n.podlove-icon-house:before {\n\tcontent: \"\\e007\";\n}\n.podlove-icon-mail:before {\n\tcontent: \"\\e008\";\n}\n.podlove-icon-cart:before {\n\tcontent: \"\\e009\";\n}\n.podlove-icon-edit:before {\n\tcontent: \"e\";\n}\n.podlove-icon-calendar:before {\n\tcontent: \"\\e953\";\n}"
  },
  {
    "path": "css/admin.css",
    "content": "/* generic */\ninput[type='color'] {\n  padding: 1px 2px;\n}\n\n.reorder-handle {\n  font-size: 20px;\n  cursor: move;\n  color: gray;\n  float: right;\n}\n\n.reorder-handle:hover {\n  color: rgb(51, 51, 51);\n}\n\ni.rotate {\n  display: inline-block;\n  -webkit-animation: Rotate 500ms infinite linear;\n  -moz-animation: Rotate 500ms infinite linear;\n  -ms-animation: Rotate 500ms infinite linear;\n  -o-animation: Rotate 500ms infinite linear;\n  animation: Rotate 500ms infinite linear;\n}\n\n@-o-keyframes Rotate {\n  from {\n    -o-transform: rotate(0deg);\n  }\n\n  to {\n    -o-transform: rotate(360deg);\n  }\n}\n\n@-moz-keyframes Rotate {\n  from {\n    -moz-transform: rotate(0deg);\n  }\n\n  to {\n    -moz-transform: rotate(360deg);\n  }\n}\n\n@-ms-keyframes Rotate {\n  from {\n    -ms-transform: rotate(0deg);\n  }\n\n  to {\n    -ms-transform: rotate(360deg);\n  }\n}\n\n@-webkit-keyframes Rotate {\n  from {\n    -webkit-transform: rotate(0deg);\n  }\n\n  to {\n    -webkit-transform: rotate(360deg);\n  }\n}\n\n@-keyframes Rotate {\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n\nul.podlove-disc-list {\n  list-style-type: disc;\n  margin-left: 40px;\n}\n\n.force-issues {\n  border-left: 4px solid #ccc;\n  padding-left: 15px;\n}\n\n.clickable {\n  cursor: pointer;\n}\n\n.podlove-icon-ok {\n  color: green;\n}\n\n.podlove-icon-remove {\n  color: red;\n}\n\n.podlove-icon-minus {\n  color: #999;\n}\n\n.podlove-contributor-edit,\n.podlove-contributor-create {\n  text-decoration: none;\n  margin-left: 6px;\n  vertical-align: middle;\n}\n\n.podlove-contributor-edit {\n  display: none;\n}\n\na[data-podlove-help] {\n  text-decoration: none;\n}\n\n/* Dashboard */\ndiv.podlove-dashboard-statistics-wrapper {\n  width: 49%;\n  display: inline-block;\n}\n\ntd.podlove-dashboard-number-column {\n  font-size: 1.2em;\n  text-align: right;\n  padding-right: 10px;\n}\n\ntd.podlove-dashboard-number-column a,\ntd.podlove-dashboard-number-column a:visited {\n  text-decoration: none;\n}\n\ntd.podlove-dashboard-total-number {\n  border-top: 1px solid #bcbcbc;\n}\n\ntable.podlove-dashboard-statistics td {\n  padding: 3px;\n}\n\n.nav-tab-wrapper .nav-tab-title {\n  float: left;\n  line-height: 24px;\n  font-size: 14px;\n  padding: 5px 10px;\n}\n\n/* Dashboard in Sidebar */\n.inner-sidebar div.podlove-dashboard-statistics-wrapper {\n  width: 100%;\n  display: block;\n}\n\n/* Settings: Assets */\n.episode_assets.wp-list-table .column-move {\n  width: 50px;\n}\n\n.episode_assets.wp-list-table .column-downloadable {\n  width: 100px;\n}\n\n.feeds.wp-list-table .column-move {\n  width: 50px;\n}\n\n.feeds.wp-list-table .column-discoverable {\n  width: 100px;\n}\n\ntable#dashboard_feed_info {\n  width: 100%;\n}\n\ntable#dashboard_feed_info td.center {\n  text-align: center;\n}\n\n/* Support */\n.podlove_system_report {\n  font-family: monospace;\n  resize: none;\n}\n\n/* Media files */\ntable.podlove_alternating th {\n  font-weight: bold;\n  padding: 1px;\n}\n\ntable.podlove_alternating {\n  width: 100%;\n  border-bottom: 1px solid #999;\n}\n\ntable.podlove_alternating th {\n  text-align: left;\n  border-bottom: 1px solid #999;\n}\n\ntable.podlove_alternating td {\n  padding: 5px;\n  height: 24px;\n}\n\ntable.podlove_alternating tr:nth-child(even) {\n  background: #eaeaea;\n}\n\n.base_url {\n  color: #777;\n  font-size: 0.9em;\n}\n\n.media_file_row .enable {\n  text-align: center;\n}\n\n.subtitle_warning {\n  float: left;\n  font-weight: bold;\n  padding-right: 10px;\n}\n\n.subtitle_warning .close {\n  cursor: pointer;\n}\n\n.media_file_row .enable {\n  width: 45px;\n}\n\n.media_file_row .size {\n  width: 130px;\n}\n\n.media_file_row .update {\n  width: 90px;\n}\n\n.row__podlove_meta_episode_assets label[for='_podlove_meta_episode_assets'] {\n  padding-bottom: 0;\n}\n\n/* Episode */\n.row__podlove_meta_cover_art .podlove-media-upload-wrap > span {\n  display: -webkit-flex;\n  display: -ms-flexbox;\n  display: flex;\n}\n\n.row__podlove_meta_cover_art .podlove-media-upload-wrap > span input {\n  -webkit-flex: initial;\n  -ms-flex: initial;\n  flex: initial;\n  width: 100%;\n  min-width: 100px;\n}\n\n.row__podlove_meta_cover_art .podlove-media-upload-wrap > span a {\n  margin-left: 10px;\n}\n\n.row__podlove_meta_duration > div > div {\n  display: -webkit-flex;\n  display: -ms-flexbox;\n  display: flex;\n}\n\n.row__podlove_meta_duration > div > div input {\n  -webkit-flex: initial;\n  -ms-flex: initial;\n  flex: initial;\n  width: 100%;\n  min-width: 100px;\n}\n\n.row__podlove_meta_duration > div > div a.button {\n  margin-left: 10px;\n}\n\n.row__podlove_meta_duration div i {\n  line-height: 26px;\n  margin-left: 10px;\n}\n\n/* Contributors */\n#add_new_contributor_selector {\n  width: 250px;\n}\n\n#add_new_contributor_wrapper,\n#add_new_episode_relation_wrapper {\n  width: 285px;\n  margin: 5px 0px 0px 0px;\n}\n\n#contributors_table_body select {\n  width: 180px;\n}\n\n#contributors_table_body td {\n  height: 40px !important;\n}\n\nspan.contributor_remove,\nspan.episode_relation_remove {\n  font-size: 1.6em;\n}\n\ntr.row_podlove_feed_protection_password,\ntr.row_podlove_feed_protection_user,\ntr.row_podlove_feed_protection_type {\n  display: none;\n}\n\nimg.podlove-avatar {\n  width: 50px;\n  vertical-align: bottom;\n  margin: 2px;\n}\n\ninput.podlove-contributor-field {\n  width: 350px;\n}\n\n/* Podlove Podcast License */\n#podlove_podcast_license_name,\n#podlove_podcast_license_url {\n  width: 400px;\n}\n\ndiv.podlove_cc_license,\ndiv.podlove_license,\n.podlove_podcast_license_image {\n  display: block;\n  width: 300px;\n  text-align: center;\n}\n\n._podlove_episode_list_triangle,\n._podlove_episode_list_triangle_expanded {\n  width: 20px;\n  font-size: 0.8em;\n}\n\n._podlove_episode_list_triangle_expanded {\n  display: none;\n}\n\nspan#podlove_cc_license_selector_toggle {\n  font-weight: bold;\n  cursor: pointer;\n}\n\n.row_podlove_cc_license_selector {\n  display: none;\n}\n\n.row_podlove_cc_license_selector td div {\n  padding: 0px 5px 5px 30px;\n}\n\n.row_podlove_cc_license_selector td div select {\n  width: 240px;\n}\n\nlabel.podlove_cc_license_selector_label {\n  font-style: italic;\n  width: 240px;\n  display: inline-block;\n  clear: both;\n}\n\n.row_podlove_contributor_services_form_table td,\n.row_podlove_contributor_services_form_table td {\n  padding: 0;\n}\n\n/* Podlove input field validation */\n.podlove-invalid-input {\n  border: 2px solid #c72a00 !important;\n}\n\n.podlove-input-isinvalid {\n  display: inline-block;\n  background-color: #c72a00;\n  color: #fff;\n  height: 29px;\n  line-height: 30px;\n  padding: 0 5px 0 5px;\n  margin-left: -1px;\n}\n\n.podlove-hide {\n  display: none;\n}\n\n/* podcast post meta box */\n.media_file_table {\n  width: 100%;\n  border-bottom: 1px solid #999;\n}\n\n.media_file_table th {\n  text-align: left;\n  border-bottom: 1px solid #999;\n}\n\n.media_file_table th.verify_all {\n  padding: 0 0 5px 5px;\n}\n\n.media_file_table th.verify_all a {\n  font-weight: normal;\n  margin: 0;\n}\n\n.media_file_table td {\n  padding: 5px;\n  height: 24px;\n}\n\n.media_file_table tr:nth-child(even) {\n  background: #eaeaea;\n}\n\n.podlove-div-wrapper-form > div > span > label {\n  display: inline-block;\n  padding: 15px 0 6px 0;\n  font-size: 1.2em;\n}\n\n.podlove-div-wrapper-form textarea,\n.podlove-div-wrapper-form input[type='text'],\n.podlove-div-wrapper-form select {\n  margin: 0px;\n  width: 100%;\n}\n\n.podlove-div-wrapper-form .character_counter {\n  text-align: right;\n  float: right;\n}\n\n#template-editor {\n  background: white;\n  border: 1px solid rgb(229, 229, 229);\n  margin-top: 15px;\n  padding: 0;\n  box-sizing: border-box;\n}\n\n#template-editor a {\n  text-decoration: none;\n}\n\n#template-editor a:focus,\n#template-editor a:active {\n  outline: 0;\n}\n\n#template-editor .navigation {\n  background: #f9f9f9;\n  width: 20%;\n  float: left;\n  position: relative;\n  min-height: 445px;\n  box-sizing: border-box;\n  font-weight: bold;\n}\n\n#template-editor .navigation span {\n  vertical-align: middle;\n}\n\n#template-editor .navigation .unsaved {\n  font-size: 150%;\n}\n\n#template-editor .navigation .add {\n  position: absolute;\n  bottom: 8px;\n  left: 10px;\n}\n\n#template-editor .navigation ul {\n  margin: 0;\n  height: 418px;\n  overflow-y: scroll;\n}\n\n#template-editor .navigation ul li {\n  padding: 6px 10px;\n  margin: 0;\n}\n\n#template-editor .navigation ul a {\n  display: block;\n}\n\n#template-editor .navigation ul li.active {\n  background: #d7d7d7;\n}\n\n#template-editor .editor {\n  float: left;\n  width: 80%;\n  box-sizing: border-box;\n}\n\n#template-editor .toolbar {\n  border-bottom: 1px solid rgb(229, 229, 229);\n  padding: 8px 10px;\n  font-size: 14px;\n  width: 100%;\n  box-sizing: border-box;\n}\n\n#template-editor .toolbar input[type='text'] {\n  width: 100%;\n}\n\n#template-editor .toolbar .title {\n  float: left;\n  line-height: 26px;\n  width: 50%;\n}\n\n#template-editor footer {\n  border-top: 1px solid #f9f9f9;\n  padding: 8px 10px;\n}\n\n#template-editor footer a.delete {\n  color: red;\n  line-height: 26px;\n  padding-right: 5px;\n}\n\n#template-editor footer .actions {\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n  align-content: center;\n}\n\n#template-editor .editor .main {\n  /*padding: 8px 10px;*/\n  height: 400px;\n}\n\n.fullscreen-button.fullscreen-off {\n  background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGhlaWdodD0iMzJweCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzIgMzI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMYXllcl8xIi8+PGcgaWQ9ImZ1bGxzY3JlZW5feDVGX2V4aXQiPjxnPjxwb2x5Z29uIHBvaW50cz0iMjQuNTg2LDI3LjQxNCAyOS4xNzIsMzIgMzIsMjkuMTcyIDI3LjQxNCwyNC41ODYgMzIsMjAgMjAsMjAgMjAsMzIgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIwLDEyIDEyLDEyIDEyLDAgNy40MTQsNC41ODYgMi44NzUsMC4wNDMgMC4wNDcsMi44NzEgNC41ODYsNy40MTQgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIwLDI5LjE3MiAyLjgyOCwzMiA3LjQxNCwyNy40MTQgMTIsMzIgMTIsMjAgMCwyMCA0LjU4NiwyNC41ODYgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIyMCwxMiAzMiwxMiAyNy40MTQsNy40MTQgMzEuOTYxLDIuODcxIDI5LjEzMywwLjA0MyAyNC41ODYsNC41ODYgMjAsMCAgICIgc3R5bGU9ImZpbGw6IzRFNEU1MDsiLz48L2c+PC9nPjwvc3ZnPg==');\n}\n\n.fullscreen-button:hover {\n  opacity: 1;\n}\n\n.fullscreen-button {\n  display: block;\n  position: absolute;\n  z-index: 100001;\n  padding: 0;\n  margin: 0;\n  height: 32px;\n  width: 32px;\n  background-repeat: no-repeat;\n  cursor: pointer;\n  background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGhlaWdodD0iMzJweCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzIgMzI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMYXllcl8xIi8+PGcgaWQ9ImZ1bGxzY3JlZW4iPjxnPjxwb2x5Z29uIHBvaW50cz0iMjcuNDE0LDI0LjU4NiAyMi44MjgsMjAgMjAsMjIuODI4IDI0LjU4NiwyNy40MTQgMjAsMzIgMzIsMzIgMzIsMjAgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIxMiwwIDAsMCAwLDEyIDQuNTg2LDcuNDE0IDkuMTI5LDExLjk1MyAxMS45NTcsOS4xMjUgNy40MTQsNC41ODYgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIxMiwyMi44MjggOS4xNzIsMjAgNC41ODYsMjQuNTg2IDAsMjAgMCwzMiAxMiwzMiA3LjQxNCwyNy40MTQgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIzMiwwIDIwLDAgMjQuNTg2LDQuNTg2IDIwLjA0Myw5LjEyNSAyMi44NzEsMTEuOTUzIDI3LjQxNCw3LjQxNCAzMiwxMiAgICIgc3R5bGU9ImZpbGw6IzRFNEU1MDsiLz48L2c+PC9nPjwvc3ZnPg==');\n  opacity: 0.3;\n  top: 5px;\n  right: 5px;\n}\n\n.fullScreen .fullScreen-editor {\n  height: auto !important;\n  width: auto !important;\n  border: 0;\n  margin: 0;\n  position: fixed !important;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  z-index: 100000;\n}\n\n.fullScreen {\n  overflow: hidden;\n}\n\n.editor-wrapper {\n  position: relative;\n}\n\n.fullScreen #wpwrap,\n.fullScreen #wpbody,\n.fullScreen .editor-wrapper {\n  position: static;\n}\n\n.podlove_gender_widget_column {\n  min-width: 300px;\n  display: inline-block;\n}\n\n.podlove_gender_widget_column table {\n  width: 95%;\n}\n\n.podlove_gender_widget_column thead th {\n  border-bottom: 1px solid #cdcdcd;\n}\n\n.podlove_gender_widget_column td,\n.podlove_gender_widget_column th {\n  text-align: right;\n  padding-right: 2%;\n}\n\n/* podlove media upload */\n.podlove_preview_pic {\n  margin: 1px;\n  position: relative;\n  min-height: 40px;\n  display: none;\n}\n\n.podlove_preview_pic img {\n  border: 1px solid #ddd;\n}\n\n.podlove_preview_pic .podlove_reset_image {\n  position: absolute;\n  bottom: 5px;\n  color: #a00;\n}\n\n.analytics-metric-container {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n@media (min-width: 640px) {\n  .analytics-metric-container {\n    grid-template-columns: repeat(4, minmax(0, 1fr));\n  }\n}\n\n.analytics-metric-box {\n  text-align: center;\n  margin: 10px 20px;\n  min-width: 120px;\n}\n\n.analytics-metric-box > span,\n#analytics-global-downloads > div {\n  font-size: 23px;\n  line-height: 23px;\n  display: block;\n}\n\n.analytics-metric-box .analytics-value,\n#analytics-global-downloads .analytics-value {\n  font-weight: bold;\n  line-height: 40px;\n}\n\n.analytics-metric-box .analytics-description,\n.analytics-metric-box .analytics-subtext,\n#analytics-global-downloads .analytics-description {\n  font-size: 14px;\n  line-height: 16px;\n  color: #666;\n}\n\n.downloads.striped > tbody > :nth-child(odd) {\n  background: inherit;\n}\n\n.downloads.striped > tbody > :nth-child(4n + 1),\n.downloads.striped > tbody > :nth-child(4n + 2) {\n  background: #f9f9f9;\n}\n\n.downloads .dashicons {\n  font-size: 13px;\n  line-height: 1.5em;\n}\n\n#the-list .downloads-description {\n  display: table-cell;\n}\n\n.wp-list-table .column-episode_number {\n  width: 30px;\n}\n\nsection.chart-wrapper {\n  float: left;\n  height: 320px;\n}\n\nsection.chart-wrapper h1 {\n  font-size: 14px;\n  margin-left: 10px;\n}\n\nsection.chart-wrapper div {\n  width: 285px;\n  height: 285px;\n}\n\n.chart-wrapper h1,\n.chart-wrapper h1 small {\n  line-height: 19px;\n  height: 19px;\n}\n\n.chart-wrapper h1 a {\n  text-decoration: none;\n}\n\n.chart-menubar:first-child {\n  float: right;\n}\n\n.chart-menubar:last-child {\n  float: left;\n}\n\n.chart-menubar span {\n  line-height: 26px;\n}\n\n#episode-performance-chart {\n  float: none;\n  height: 250px;\n}\n\n#episode-range-chart {\n  float: none;\n  height: 80px;\n  margin-top: -15px;\n}\n\n#episode-source-chart g.row text,\n#episode-context-chart g.row text,\n#episode-client-chart g.row text,\n#episode-system-chart g.row text,\n#episode-geo-chart g.row text,\n#episode-asset-chart g.row text,\n#analytics-chart-global-clients g.row text,\n#analytics-chart-global-systems g.row text,\n#analytics-chart-global-sources g.row text,\n#analytics-global-top-episodes g.row text,\n#analytics-chart-global-assets g.row text {\n  fill: black;\n}\n\nsection.chart-wrapper div.chart-loading {\n  font-size: 2rem;\n  text-align: center;\n  margin-top: 100px;\n  height: 200px;\n}\n\n.chart-failed,\n.chart-nodata {\n  font-size: 1rem;\n  font-weight: bold;\n  text-align: center;\n  width: 100% !important;\n  margin-top: 50px;\n}\n\n.chart-failed {\n  color: rgb(212, 61, 4);\n}\n\n.chart-nodata {\n  color: #666;\n}\n\n.chart-nodata:visible ~ svg {\n  display: none;\n}\n\n/**\tslacknotes / shownotes **/\n\n.p-card {\n  background: white;\n  border: 1px solid #ddd;\n  max-width: 1024px;\n}\n\n.p-card-body {\n  padding: 12px;\n}\n\n.p-card-header,\n.p-card-footer {\n  background: #e9e9e9;\n  padding: 12px;\n}\n\n.podlove-form-card {\n  background: white;\n  display: block;\n  margin-bottom: 2em;\n  padding: 1.25em;\n  box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 4px rgba(0, 0, 0, 0.05);\n}\n\n.podlove-form-card tr:first-child th,\n.podlove-form-card tr:first-child h3 {\n  margin-top: 0;\n  padding-top: 0;\n}\n\n.podlove-form-card .submit {\n  padding-bottom: 0;\n}\n\n/** Plus Banner */\n.plus-banner {\n  background: linear-gradient(135deg, #2562eb, #00bfff);\n  border-radius: 12px;\n  padding: 24px 30px;\n  color: white;\n  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);\n  display: flex;\n  flex-direction: column;\n  max-width: 900px;\n  margin: 20px 0;\n  position: relative;\n}\n\n.plus-banner-content,\n.plus-banner-content p {\n  font-size: 14px;\n}\n\n.plus-banner h3 {\n  margin-top: 0;\n  margin-bottom: 16px;\n  font-size: 22px;\n  font-weight: 600;\n  color: white;\n}\n\n.plus-banner-content {\n}\n\n.plus-banner p {\n  margin: 0 0 16px;\n  line-height: 1.5;\n}\n\n.plus-banner .btn {\n  background-color: white;\n  color: #2562eb;\n  text-decoration: none;\n  padding: 10px 20px;\n  border-radius: 6px;\n  font-weight: 600;\n  display: inline-block;\n  transition: transform 0.2s;\n  align-self: flex-start;\n}\n\n.plus-banner .btn:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);\n}\n\n.plus-banner .logo-text {\n  font-weight: 300;\n  font-size: 14px;\n  display: flex;\n  align-items: center;\n}\n\n.plus-banner-footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.corner-logo {\n  display: flex;\n  align-items: center;\n  opacity: 0.8;\n}\n\n.plus-banner .corner-logo .logo-text {\n  margin-left: 6px;\n}\n\n.banner-feature-list {\n  margin: 16px 0;\n  padding: 0;\n  list-style: none;\n}\n\n.banner-feature-list li {\n  margin-bottom: 8px;\n  display: flex;\n  align-items: baseline;\n}\n\n.banner-feature-list li::before {\n  content: '★';\n  margin-right: 8px;\n  color: #ffd700;\n}\n\n@media (max-width: 768px) {\n  .plus-banner {\n    padding: 20px;\n  }\n\n  .plus-banner h3 {\n    line-height: 1.2;\n  }\n\n  .plus-banner-content {\n    flex-direction: column;\n  }\n\n  .plus-banner-footer {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n\n  .plus-banner .corner-logo {\n    margin-top: 15px;\n  }\n}\n"
  },
  {
    "path": "css/dc.css",
    "content": "div.dc-chart {\n    float: left;\n}\n\n.dc-chart rect.bar {\n    stroke: none;\n    cursor: pointer;\n}\n\n.dc-chart rect.bar:hover {\n    fill-opacity: .5;\n}\n\n.dc-chart rect.stack1 {\n    stroke: none;\n    fill: red;\n}\n\n.dc-chart rect.stack2 {\n    stroke: none;\n    fill: green;\n}\n\n.dc-chart rect.deselected {\n    stroke: none;\n    fill: #ccc;\n}\n\n.dc-chart .empty-chart .pie-slice path {\n    fill: #FFEEEE;\n    cursor: default;\n}\n\n.dc-chart .empty-chart .pie-slice {\n    cursor: default;\n}\n\n.dc-chart .pie-slice {\n    fill: white;\n    font-size: 12px;\n    cursor: pointer;\n}\n\n.dc-chart .pie-slice.external{\n    fill: black;\n}\n\n.dc-chart .pie-slice :hover {\n    fill-opacity: .8;\n}\n\n.dc-chart .pie-slice.highlight {\n    fill-opacity: .8;\n}\n\n.dc-chart .selected path {\n    stroke-width: 3;\n    stroke: #ccc;\n    fill-opacity: 1;\n}\n\n.dc-chart .deselected path {\n    stroke: none;\n    fill-opacity: .5;\n    fill: #ccc;\n}\n\n.dc-chart .axis path, .axis line {\n    fill: none;\n    stroke: #000;\n    shape-rendering: crispEdges;\n}\n\n.dc-chart .axis text {\n    font: 10px sans-serif;\n}\n\n.dc-chart .grid-line {\n    fill: none;\n    stroke: #ccc;\n    opacity: .5;\n    shape-rendering: crispEdges;\n}\n\n.dc-chart .grid-line line {\n    fill: none;\n    stroke: #ccc;\n    opacity: .5;\n    shape-rendering: crispEdges;\n}\n\n.dc-chart .brush rect.background {\n    z-index: -999;\n}\n\n.dc-chart .brush rect.extent {\n    fill: steelblue;\n    fill-opacity: .125;\n}\n\n.dc-chart .brush .resize path {\n    fill: #eee;\n    stroke: #666;\n}\n\n.dc-chart path.line {\n    fill: none;\n    stroke-width: 1.5px;\n}\n\n.dc-chart circle.dot {\n    stroke: none;\n}\n\n.dc-chart g.dc-tooltip path {\n    fill: none;\n    stroke: grey;\n    stroke-opacity: .8;\n}\n\n.dc-chart path.area {\n    fill-opacity: .3;\n    stroke: none;\n}\n\n.dc-chart .node {\n    font-size: 0.7em;\n    cursor: pointer;\n}\n\n.dc-chart .node :hover {\n    fill-opacity: .8;\n}\n\n.dc-chart .selected circle {\n    stroke-width: 3;\n    stroke: #ccc;\n    fill-opacity: 1;\n}\n\n.dc-chart .deselected circle {\n    stroke: none;\n    fill-opacity: .5;\n    fill: #ccc;\n}\n\n.dc-chart .bubble {\n    stroke: none;\n    fill-opacity: 0.6;\n}\n\n.dc-data-count {\n    float: right;\n    margin-top: 15px;\n    margin-right: 15px;\n}\n\n.dc-data-count .filter-count {\n    color: #3182bd;\n    font-weight: bold;\n}\n\n.dc-data-count .total-count {\n    color: #3182bd;\n    font-weight: bold;\n}\n\n.dc-data-table {\n}\n\n.dc-chart g.state {\n    cursor: pointer;\n}\n\n.dc-chart g.state :hover {\n    fill-opacity: .8;\n}\n\n.dc-chart g.state path {\n    stroke: white;\n}\n\n.dc-chart g.selected path {\n}\n\n.dc-chart g.deselected path {\n    fill: grey;\n}\n\n.dc-chart g.selected text {\n}\n\n.dc-chart g.deselected text {\n    display: none;\n}\n\n.dc-chart g.county path {\n    stroke: white;\n    fill: none;\n}\n\n.dc-chart g.debug rect {\n    fill: blue;\n    fill-opacity: .2;\n}\n\n.dc-chart g.row rect {\n    fill-opacity: 0.8;\n    cursor: pointer;\n}\n\n.dc-chart g.row rect:hover {\n    fill-opacity: 0.6;\n}\n\n.dc-chart g.row text {\n    fill: white;\n    font-size: 12px;\n    cursor: pointer;\n}\n\n.dc-legend {\n    font-size: 11px;\n}\n\n.dc-legend-item {\n    cursor: pointer;\n}\n\n.dc-chart g.axis text {\n    /* Makes it so the user can't accidentally click and select text that is meant as a label only */\n    -webkit-user-select: none; /* Chrome/Safari */\n    -moz-user-select: none; /* Firefox */\n    -ms-user-select: none; /* IE10 */\n    -o-user-select: none;\n    user-select: none;\n    pointer-events: none;\n}\n\n.dc-chart path.highlight {\n    stroke-width: 3;\n    fill-opacity: 1;\n    stroke-opacity: 1;\n}\n\n.dc-chart .highlight {\n    fill-opacity: 1;\n    stroke-opacity: 1;\n}\n\n.dc-chart .fadeout {\n    fill-opacity: 0.2;\n    stroke-opacity: 0.2;\n}\n\n.dc-chart path.dc-symbol, g.dc-legend-item.fadeout {\n    fill-opacity: 0.5;\n    stroke-opacity: 0.5;\n}\n\n.dc-hard .number-display {\n    float: none;\n}\n\n.dc-chart .box text {\n    font: 10px sans-serif;\n    -webkit-user-select: none; /* Chrome/Safari */\n    -moz-user-select: none; /* Firefox */\n    -ms-user-select: none; /* IE10 */\n    -o-user-select: none;\n    user-select: none;\n    pointer-events: none;\n}\n\n.dc-chart .box line,\n.dc-chart .box circle {\n    fill: #fff;\n    stroke: #000;\n    stroke-width: 1.5px;\n}\n\n.dc-chart .box rect {\n    stroke: #000;\n    stroke-width: 1.5px;\n}\n\n.dc-chart .box .center {\n    stroke-dasharray: 3,3;\n}\n\n.dc-chart .box .outlier {\n    fill: none;\n    stroke: #ccc;\n}\n\n.dc-chart .box.deselected .box {\n    fill: #ccc;\n}\n\n.dc-chart .box.deselected {\n    opacity: .5;\n}\n\n.dc-chart .symbol{\n    stroke: none;\n}\n\n.dc-chart .heatmap .box-group.deselected rect {\n    stroke: none;\n    fill-opacity: .5;\n    fill: #ccc;\n}\n\n.dc-chart .heatmap g.axis text {\n    pointer-events: all;\n    cursor: pointer;\n}\n"
  },
  {
    "path": "css/frontend.css",
    "content": ".episode_download_list ul {\n  list-style: none;\n}\n\n.episode_download_list li {\n  display: inline;\n  font-size: 0.75em;\n}\n\n.episode_download_list .size {\n  font-size: 0.5em;\n  padding-left: 3px;\n}\n\n.episode_downloads select {\n  font-size: 18px;\n  display: inline;\n}\n\n.episode_downloads button {\n  margin-left: 10px;\n}\n\ndiv.podlove_cc_license {\n  display: block;\n  text-align: center;\n}\n\nul.podlove-donations-list, ul.podlove-social-list {\n  margin: auto 0px auto 0px;\n}\n\nul.podlove-donations-list li a, ul.podlove-social-list li a, img.podlove-contributor-button, ul.podlove-donations-list li, ul.podlove-social-list li {\n  width: 20px;\n  height: 20px;\n}\n\nul.podlove-donations-list li a, ul.podlove-social-list li a {\n  display: block;\n}\n\nul.podlove-donations-list li, ul.podlove-social-list li {\n  display: inline-block;\n  line-height: 20px;\n  list-style-type: none;\n  margin-right: 5px;\n}\n\n/* player */\n\n.podlove-player-wrapper {\n  overflow: auto;\n  -webkit-overflow-scrolling: touch;\n}\n\n.podlove-player-wrapper iframe {\n  width: 1px;\n  min-width: 100%;\n}\n\n.podlovewebplayer_wrapper {\n  margin-bottom: 10px;\n}\n"
  },
  {
    "path": "data/.gitkeep",
    "content": ""
  },
  {
    "path": "data/opawg.json",
    "content": "[{\n\t\"user_agents\": [\"^Acast.+[Aa]ndroid\"],\n\t\"app\": \"Acast\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Acast.+iOS\"],\n\t\"app\": \"Acast\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"AdsBot-Google\"],\n\t\"app\": \"Google AdsBot\",\n\t\"bot\": true,\n\t\"info_url\": \"http:\\/\\/www.google.com\\/adsbot.html\"\n}, {\n\t\"user_agents\": [\"AhrefsBot\\/\"],\n\t\"app\": \"AhrefsBot\",\n\t\"bot\": true,\n\t\"info_url\": \"http:\\/\\/ahrefs.com\\/robot\\/\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; AhrefsBot\\/7.0; http:\\/\\/ahrefs.com\\/robot\\/)\"]\n}, {\n\t\"user_agents\": [\"^Airr\\/\"],\n\t\"app\": \"Airr\",\n\t\"info_url\": \"https:\\/\\/www.airr.io\\/\",\n\t\"examples\": [\"Airr\\/3787 CFNetwork\\/1128.0.1 Darwin\\/19.6.0\", \"Airr\\/4070 CFNetwork\\/1206 Darwin\\/20.1.0\"]\n}, {\n\t\"user_agents\": [\"^AirableBot-Podcast\"],\n\t\"app\": \"Airable\",\n\t\"info_url\": \"https:\\/\\/www.airablenow.com\\/\",\n\t\"examples\": [\"AirableBot-Podcast\\/1.0 ( https\\/\\/www.airablenow.com)\"]\n}, {\n\t\"user_agents\": [\"^AlexaMediaPlayer\\/1\\\\.\", \"^AlexaMediaPlayer\\/16\\\\.\", \"^AlexaMediaPlayer\\/2\\\\.\", \"^Echo.*APNG\"],\n\t\"app\": \"Alexa-enabled device\",\n\t\"device\": \"speaker\",\n\t\"os\": \"alexa\",\n\t\"svg\": \"amazon.svg\",\n\t\"examples\": [\"Echo\\/1.0(APNG)\"]\n}, {\n\t\"user_agents\": [\"^AmazonNewsContentService\"],\n\t\"app\": \"Alexa Flash Briefing cache\",\n\t\"description\": \"A service which downloads, caches and normalises audio for the Flash Briefing service on Alexa-enabled devices\",\n\t\"os\": \"alexa\",\n\t\"info_url\": \"https:\\/\\/developer.amazon.com\\/docs\\/flashbriefing\\/flash-briefing-skill-api-feed-reference.html#performance-requirements\",\n\t\"developer_notes\": \"Stats are available within the Alexa skills dashboard.\",\n\t\"svg\": \"amazon.svg\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^AmazonMusic(?!.*iPhone|.*Android|.*iPad)\"],\n\t\"examples\": [\"AmazonMusic\"],\n\t\"app\": \"Amazon Music Podcasts\",\n\t\"description\": \"A music and podcasts streaming app\",\n\t\"svg\": \"amazon.svg\"\n}, {\n\t\"user_agents\": [\"^AmazonMusic.*iPhone\"],\n\t\"examples\": [\"AmazonMusic\\/9.15.2 iPhone7,2 CFNetwork\\/978.0.7 Darwin\\/18.7.0\", \"AmazonMusic\\/9.16.1 iPhone9,1 CFNetwork\\/1128.0.1 Darwin\\/19.6.0\", \"AmazonMusic\\/9.16.0 iPhone12,1 CFNetwork\\/1128.0.1 Darwin\\/19.6.0\"],\n\t\"app\": \"Amazon Music Podcasts\",\n\t\"description\": \"A music and podcasts streaming app\",\n\t\"os\": \"ios\",\n\t\"device\": \"phone\",\n\t\"developer_notes\": \"Examples are from an Amazon contact\",\n\t\"svg\": \"amazon.svg\"\n}, {\n\t\"user_agents\": [\"^AmazonMusic.*iPad\"],\n\t\"examples\": [\"AmazonMusic/22.13.3 iPad7,3 CFNetwork/1335.0.3 Darwin/21.6.0\"],\n\t\"app\": \"Amazon Music Podcasts\",\n\t\"description\": \"A music and podcasts streaming app\",\n\t\"os\": \"ipados\",\n\t\"device\": \"tablet\"\n}, {\n\t\"user_agents\": [\"^AmazonMusic.*Android\"],\n\t\"examples\": [\"AmazonMusic\\/16.17.0 Dalvik\\/2.1.0 (Linux; U; Android 6.0.1; vivo 1610 Build\\/MMB29M)\"],\n\t\"app\": \"Amazon Music Podcasts\",\n\t\"description\": \"A music and podcasts streaming app\",\n\t\"os\": \"android\",\n\t\"developer_notes\": \"Examples are from an Amazon contact\",\n\t\"svg\": \"amazon.svg\"\n}, {\n\t\"user_agents\": [\"^Amazon Music Podcast\"],\n\t\"app\": \"Amazon Music Podcasts\",\n\t\"description\": \"A music and podcasts streaming app\",\n\t\"developer_notes\": \"Backend ingestion service\",\n\t\"svg\": \"amazon.svg\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^AlexaMediaPlayer\\/5\\\\.\"],\n\t\"app\": \"Amazon Echo Dot\",\n\t\"device\": \"speaker\",\n\t\"os\": \"alexa\",\n\t\"svg\": \"amazon.svg\"\n}, {\n\t\"user_agents\": [\"^com.audible.playersdk.player\", \"^Audible,\"],\n\t\"app\": \"Audible\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Audible.*Darwin\"],\n\t\"app\": \"Audible\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Android_AudioNow\\/\"],\n\t\"app\": \"Audio Now\",\n\t\"examples\": [\"Android_AudioNow\"],\n\t\"info_url\": \"https:\\/\\/audionow.de\\/\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^AndroidDownloadManager\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^AntennaPod\\/\", \"^de.danoeh.antennapod\\/\"],\n\t\"app\": \"AntennaPod\",\n\t\"examples\": [\"de.danoeh.antennapod\\/1.7.3b (Linux;Android 8.0.0) ExoPlayerLib\\/2.9.3\"],\n\t\"info_url\": \"https:\\/\\/github.com\\/AntennaPod\\/AntennaPod\",\n\t\"os\": \"android\",\n\t\"developer_notes\": \"The de.danoeh version was used when streaming only, and will been phased out as of v2\"\n}, {\n\t\"user_agents\": [\"Apache-HttpClient\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Applebot\\/\"],\n\t\"bot\": true,\n\t\"info_url\": \"http:\\/\\/www.apple.com\\/go\\/applebot\",\n\t\"description\": \"Applebot is the web crawler for Apple. Products like Siri and Spotlight Suggestions use Applebot.\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*iPod\"],\n\t\"device\": \"mp3_player\",\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.16G114 (iPod touch; U; CPU OS 12_4_2 like Mac OS X; en_us)\"],\n\t\"os\": \"ios\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*Macintosh\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19A583 (Macintosh; U; Intel Mac OS X 10_15; en_us)\"],\n\t\"device\": \"pc\",\n\t\"os\": \"macos\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*iPhone\"],\n\t\"device\": \"phone\",\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.15G77 (iPhone; U; CPU OS 11_4_1 like Mac OS X; en_us)\"],\n\t\"os\": \"ios\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*iPad\"],\n\t\"device\": \"tablet\",\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.17A860 (iPad; U; CPU OS 13_1_2 like Mac OS X; en_us)\"],\n\t\"os\": \"ios\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*HomePod\"],\n\t\"device\": \"speaker\",\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.16G78 (HomePod; U; CPU OS 12_4 like Mac OS X; en_us)\"],\n\t\"os\": \"homepodos\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*Apple TV\"],\n\t\"device\": \"tv\",\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.17J586 (Apple TV; U; CPU OS 13_0 like Mac OS X; en_us)\"],\n\t\"os\": \"tvos\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^AppleCoreMedia\\/1\\\\..*Apple Watch\"],\n\t\"device\": \"watch\",\n\t\"os\": \"watchos\",\n\t\"description\": \"AppleCoreMedia library\",\n\t\"info_url\": \"https:\\/\\/podnews.net\\/article\\/applecoremedia-user-agent\",\n\t\"developer_notes\": \"This is a library used by a number of apps when progressively downloading podcasts. It is not (just) Apple Podcasts, and should not be treated as an Apple Podcasts useragent\"\n}, {\n\t\"user_agents\": [\"^Audacious\"],\n\t\"examples\": [\"Audacious/3.10.1 neon/0.30.2\"],\n\t\"device\": \"pc\",\n\t\"description\": \"Audacious is an open source audio player.\",\n\t\"info_url\": \"https:\\/\\/audacious-media-player.org\\/\"\n}, {\n\t\"user_agents\": [\"^atc\\/\"],\n\t\"app\": \"Apple Podcasts\",\n\t\"device\": \"watch\",\n\t\"os\": \"watchos\",\n\t\"bot\": true,\n\t\"developer_notes\": \"Verified (via stamping the audio URL with the RSS useragent) as being sourced from Apple Podcasts; and accordingly this is marked as a bot since these downloads are duplicated with the phone.\",\n\t\"examples\": [\"atc\\/1.0\",\"atc\\/1.0 watchOS\\/6.2 model\\/Watch3,3 hwp\\/t8004 build\\/17T529 (6; dt:155)\", \"atc\\/1.0 watchOS\\/6.2.8 model\\/Watch2,3 hwp\\/t8002 build\\/17U63 (6; dt:133)\", \"atc\\/1.0 watchOS\\/6.2.8 model\\/Watch3,3 hwp\\/t8004 build\\/17U63 (6; dt:155)\", \"atc\\/1.0 watchOS\\/6.2.8 model\\/Watch4,2 hwp\\/t8006 build\\/17U63 (6; dt:191)\", \"atc\\/1.0 watchOS\\/7.0.2 model\\/Watch5,10 hwp\\/t8006 build\\/18R402 (6; dt:233)\", \"atc\\/1.0 watchOS\\/7.0.2 model\\/Watch5,11 hwp\\/t8006 build\\/18R402 (6; dt:234)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch4,2 hwp\\/t8006 build\\/18R590 (6; dt:191)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch4,3 hwp\\/t8006 build\\/18R590 (6; dt:192)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch4,4 hwp\\/t8006 build\\/18R590 (6; dt:193)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch5,1 hwp\\/t8006 build\\/18R590 (6; dt:201)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch5,3 hwp\\/t8006 build\\/18R590 (6; dt:202)\", \"atc\\/1.0 watchOS\\/7.1 model\\/Watch5,4 hwp\\/t8006 build\\/18R590 (6; dt:202)\"]\n}, {\n\t\"user_agents\": [\"^Podcasts\\/.*\", \"^Balados\\/.*\\\\(.*\\\\)\", \"^Podcasti\\/.*\\\\(.*\\\\)\", \"^Podcastit\\/.*\\\\(.*\\\\)\", \"^Podcasturi\\/.*\\\\(.*\\\\)\", \"^Podcasty\\/.*\\\\(.*\\\\)\", \"^Podcast\\u2019ler\\/.*\\\\(.*\\\\)\", \"^Podkaster\\/.*\\\\(.*\\\\)\", \"^Podcaster\\/.*\\\\(.*\\\\)\", \"^Podcastok\\/.*\\\\(.*\\\\)\", \"^\\u041f\\u043e\\u0434\\u043a\\u0430\\u0441\\u0442\\u0438\\/.*\\\\(.*\\\\)\", \"^\\u041f\\u043e\\u0434\\u043a\\u0430\\u0441\\u0442\\u044b\\/.*\\\\(.*\\\\)\", \"^\\u05e4\\u05d5\\u05d3\\u05e7\\u05d0\\u05e1\\u05d8\\u05d9\\u05dd\\/.*\\\\(.*\\\\)\", \"^\\u0627\\u0644\\u0628\\u0648\\u062f\\u0643\\u0627\\u0633\\u062a\\/.*\\\\(.*\\\\)\", \"^\\u092a\\u0949\\u0921\\u0915\\u093e\\u0938\\u094d\\u091f\\/.*\\\\(.*\\\\)\", \"^\\u0e1e\\u0e47\\u0e2d\\u0e14\\u0e04\\u0e32\\u0e2a\\u0e17\\u0e4c\\/.*\\\\(.*\\\\)\", \"^\\u64ad\\u5ba2\\/.*\\\\(.*\\\\)\", \"^\\ud31f\\uce90\\uc2a4\\ud2b8\\/.*\\\\(.*\\\\)\"],\n\t\"examples\": [\"Podcasts\\/1410.53 CFNetwork\\/1111 Darwin\\/19.0.0 (x86_64)\", \"Podcaster\\/1410.53 CFNetwork\\/1111 Darwin\\/19.0.0 (x86_64)\"],\n\t\"app\": \"Apple Podcasts\",\n\t\"description\": \"The Apple Podcasts app.\",\n\t\"developer_notes\": \"This could be on iOS, iPadOS or macOS. Used when downloading podcasts (not progressive downloads), with support for the following languages: Arabic, Chinese, Finnish, French, English, Hebrew, Hindi, Hungarian, Korean, Polish, Romanian, Russian, Serbian, Slovenian, Swedish, Thai, Turkish.\"\n}, {\n\t\"user_agents\": [\"^Armadillo\\/1\"],\n\t\"examples\": [\"Armadillo/12.19 (Linux;Android 11) ExoPlayerLib/2.17.1\"],\n\t\"os\": \"android\",\n\t\"developer_notes\": \"This is a library, and not an app\",\n\t\"info_url\": \"https:\\/\\/tech.scribd.com\\/blog\\/2021\\/android-audio-player-tutorial-with-armadillo.html\"\n}, {\n\t\"user_agents\": [\"^AudioWave\\/1\"],\n\t\"app\": \"AudioWave\",\n\t\"examples\": [\"AudioWave\\/1.5 (+https:\\/\\/audiowave.io\\/; iPhone 15.4)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^AudioWaveBot\\/1.0\"],\n\t\"app\": \"AudioWave feed parser\",\n\t\"examples\": [\"AudioWaveBot\\/1.0\"],\n\t\"info_url\": \"https:\\/\\/audiowave.io\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^BashPodder\"],\n\t\"app\": \"BashPodder\",\n\t\"device\": \"pc\",\n\t\"info_url\": \"http:\\/\\/lincgeek.org\\/bashpodder\\/\"\n}, {\n\t\"user_agents\": [\"Barkrowler\\/\"],\n\t\"app\": \"Babbar\",\n\t\"bot\": true,\n\t\"info_url\": \"https:\\/\\/beta.babbar.tech\\/crawler\"\n}, {\n\t\"user_agents\": [\"BBC%20Sounds\\/\"],\n\t\"app\": \"BBC Sounds\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"BBC%20Sounds\\/1.13.1.7716 CFNetwork\\/1107.1 Darwin\\/19.0.0\"],\n\t\"info_url\": \"https:\\/\\/www.bbc.co.uk\\/sounds\\/help\\/questions\\/getting-started-with-bbc-sounds\\/sounds-intro\"\n}, {\n\t\"user_agents\": [\"BBCiPlayerRadio\\/\"],\n\t\"app\": \"BBC iPlayer Radio\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"BBCiPlayerRadio\\/2.16.0.8764 CFNetwork\\/1107.1 Darwin\\/19.0.0\"],\n\t\"info_url\": \"https:\\/\\/www.bbc.co.uk\\/programmes\\/p00zh17p\"\n}, {\n\t\"user_agents\": [\"; BeyondPod\"],\n\t\"app\": \"BeyondPod\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Mozilla\\/5.0 (Linux; U; en-us; BeyondPod 4)\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Bitcast\\/\"],\n\t\"app\": \"Bitcast\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/bitcast.fm\\/\",\n\t\"examples\": [\"Bitcast\\/336 CFNetwork\\/1197 Darwin\\/20.0.0\"]\n}, {\n\t\"user_agents\": [\"^Bose\\/\"],\n\t\"app\": \"Bose SoundTouch\",\n\t\"device\": \"speaker\"\n}, {\n\t\"user_agents\": [\"^Breaker\\/Android\"],\n\t\"app\": \"Breaker\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Breaker\\/iOS\"],\n\t\"app\": \"Breaker\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Android.+(?:B|b)rave\"],\n\t\"app\": \"Brave\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Linux.+(?:B|b)rave\"],\n\t\"app\": \"Brave\",\n\t\"device\": \"pc\",\n\t\"os\": \"linux\"\n}, {\n\t\"user_agents\": [\"iPhone.+(?:B|b)rave\"],\n\t\"app\": \"Brave\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Mac OS X.+(?:B|b)rave\"],\n\t\"app\": \"Brave\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"Windows.+(?:B|b)rave\"],\n\t\"app\": \"Brave\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"BroadwayPodcastNetwork\\/iOS\"],\n\t\"app\": \"Broadway Podcast Network\",\n\t\"description\": \"The Broadway Podcast Network iOS App\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"BroadwayPodcastNetwork\\/iOS\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Castamatic\\/.+Darwin\"],\n\t\"app\": \"Castamatic\",\n\t\"description\": \"Your new favorite podcast player for iOS devices\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Castamatic\\/3847 CFNetwork\\/1240.0.4 Darwin\\/20.6.0\"],\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/castamatic.com\"\n}, {\n\t\"user_agents\": [\"^Cast(?:b|B)ox\\/.+Android\"],\n\t\"app\": \"CastBox\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"CastBox\\/8.2.6-191015245 (Linux;Android 10) ExoPlayerLib\\/2.10.4\", \"CastBox\\/8.19.0-200927161 (Linux;Android 10) ExoPlayerLib\\/2.10.4\", \"CastBox\\/8.18.1-200917153 (Linux;Android 8.0.0) ExoPlayerLib\\/2.10.4\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Cast(?:b|B)ox\\/.+iOS\"],\n\t\"app\": \"CastBox\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"CastBox\\/8.5.1 (fm.castbox.audiobook.radio.podcast; build:11; iOS 13.1.2)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Cast(?:b|B)ox(?!.*(Android|iOS))\"],\n\t\"app\": \"CastBox\",\n\t\"developer_notes\": \"There are CastBox compatible User Agents that come without Android\\/iOS platform marker\",\n\t\"examples\": [\"CastBox\\/5.7.5-190508115.r1a805d3\"]\n}, {\n\t\"user_agents\": [\"^castget \"],\n\t\"app\": \"castget\",\n\t\"examples\": [\"castget 1.2.4 (castget rss enclosure downloader)\"],\n\t\"info_url\": \"https:\\/\\/castget.johndal.com\\/\",\n\t\"device\": \"pc\"\n}, {\n\t\"user_agents\": [\"Castopod\\/1.0\"],\n\t\"app\": \"Castopod\",\n\t\"examples\": [\"Castopod\\/1.0\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"Castro \"],\n\t\"app\": \"Castro\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Castro 2019.13\\/1167\", \"Castro 2020.14\\/1287\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"(Linux).* CrKey\\/\"],\n\t\"app\": \"Chromecast device\",\n\t\"device\": \"speaker\",\n\t\"os\": \"linux\"\n}, {\n\t\"user_agents\": [\"(Fuchsia).* CrKey\\/\"],\n\t\"app\": \"Google Nest Hub\",\n\t\"device\": \"speaker\",\n\t\"os\": \"fuschia\"\n}, {\n\t\"user_agents\": [\"^Clementine \"],\n\t\"app\": \"Clementine Music Player\",\n\t\"device\": \"pc\",\n\t\"info_url\": \"https:\\/\\/www.clementine-player.org\\/\"\n}, {\n\t\"user_agents\": [\"^clark-crawler2\"],\n\t\"app\": \"Clark-Crawler, unknown\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^curl\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Dalvik\\/\"],\n\t\"examples\": [\"Dalvik\\/2.1.0 (Linux; U; Android 9; SM-N950U Build\\/PPR1.180610.011)\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^datagnionbot\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Deezer\\/.*Android;\", \"^DeezerJukebox\\/.+Android\"],\n\t\"app\": \"Deezer\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Deezer\\/6.2.2.80 (Android; 9; Mobile; fr) samsung SM-G950F\", \"Deezer\\/6.2.3.96 (Android; 10; Mobile; fr) samsung SM-A405FN\", \"DeezerJukebox\\/6.2.26.58 (Linux;Android 10) ExoPlayerLib\\/2.12.1\"],\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=deezer.android.app\"\n}, {\n\t\"user_agents\": [\"^Deezer\\/.*CFNetwork\\/\"],\n\t\"app\": \"Deezer\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Deezer\\/8.13.0.4 CFNetwork\\/1125.2 Darwin\\/19.4.0\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/deezer-music-podcast-player\\/id292738169\"\n}, {\n\t\"user_agents\": [\"^Deezer.*Electron; windows\"],\n\t\"app\": \"Deezer\",\n\t\"examples\": [\"Deezer\\/4.20.0 (Electron; windows\\/10.0.18362; Desktop; fr)\"],\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^Deezer.*Electron; osx\"],\n\t\"app\": \"Deezer\",\n\t\"examples\": [\"Deezer\\/4.20.0 (Electron; osx\\/10.14.6; Desktop; fr)\"],\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"DoggCatcher\"],\n\t\"app\": \"DoggCatcher\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Mozilla\\/5.0 (Linux; U; Windows NT 6.1; en-us; dream) DoggCatcher\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"DotBot\"],\n\t\"app\": \"DotBot\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; DotBot\\/1.1; http:\\/\\/www.opensiteexplorer.org\\/dotbot, help@moz.com)\", \"Mozilla\\/5.0 (compatible; DotBot\\/1.2; https:\\/\\/opensiteexplorer.org\\/dotbot; help@moz.com)\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^doubleTwist CloudPlayer\"],\n\t\"examples\": [\"doubleTwist CloudPlayer\"],\n\t\"app\": \"doubleTwist CloudPlayer\",\n\t\"device\": \"phone\",\n\t\"info_url\": \"https:\\/\\/www.doubletwist.com\\/cloudplayer\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Downcast\\/.*iPhone\"],\n\t\"app\": \"Downcast\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Downcast\\/2.9.42 (iPhone; iOS 12.4.1; Scale\\/3.00)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Downcast\\/.*iPad\"],\n\t\"app\": \"Downcast\",\n\t\"device\": \"tablet\",\n\t\"examples\": [\"Downcast\\/2.9.57 (iPad; iOS 14.2; Scale\\/2.00)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Downcast\\/.*Mac OS X\"],\n\t\"app\": \"Downcast\",\n\t\"examples\": [\"Downcast\\/2.9.57 (Mac OS X Version 10.15.7 (Build 19H15))\"],\n\t\"os\": \"macos\",\n\t\"device\": \"pc\"\n}, {\n\t\"user_agents\": [\"downcast feed consumer\\/\"],\n\t\"app\": \"Downcast\",\n\t\"examples\": [\"downcast feed consumer\\/0.0.175; (mode=dev; id=u2NgjBSPM6; downcast.fm)\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"Xbox.+Edg?\\/\"],\n\t\"app\": \"Edge\",\n\t\"device\": \"games_console\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"Android.+EdgA\\/\"],\n\t\"app\": \"Microsoft Edge\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.microsoft.emmx&hl=en_AU&gl=US\"\n}, {\n\t\"user_agents\": [\"iPhone.+EdgiOS\\/\"],\n\t\"app\": \"Edge\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Macintosh.+MacEdgeClient\\/\"],\n\t\"app\": \"Edge\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"Windows Phone.+Edge?\\/\"],\n\t\"app\": \"Edge\",\n\t\"device\": \"phone\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"Windows(?!.*(Xbox)).+Edg?\\/\"],\n\t\"app\": \"Edge\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Mozilla\\/5.0 (Windows NT 10.0; Win64; x64; WebView\\/3.0) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/58.0.3029.110 Safari\\/537.36 Edge\\/16.16299\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"FacebookBot\", \"facebookexternalhit\\/\", \"podcastbot\"],\n\t\"bot\": true,\n\t\"app\": \"Facebook\",\n\t\"info_url\": \"https:\\/\\/www.facebook.com\\/externalhit_uatext.php\",\n\t\"developer_notes\": \"The podcastbot UA appears to be part of Facebook Podcasts onboarding\",\n\t\"examples\": [\"facebookexternalhit\\/1.1 ( http:\\/\\/www.facebook.com\\/externalhit_uatext.php)\", \"podcastbot\"]\n}, {\n\t\"user_agents\": [\"iPhone.+\\\\[FBAN\\/FBIO.+\\\\]\"],\n\t\"app\": \"Facebook\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Mozilla\\/5.0 (iPhone; CPU iPhone OS 12_4_8 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) Mobile\\/15E148 [FBAN\\/FBIOS;FBDV\\/iPhone7,2;FBMD\\/iPhone;FBSN\\/iOS;FBSV\\/12.4.8;FBSS\\/2;FBID\\/phone;FBLC\\/de_DE;FBOP\\/5]\"],\n\t\"description\": \"The Facebook app's built-in browser on iPhones.\"\n}, {\n\t\"user_agents\": [\"iOS\\/Facebook\"],\n\t\"app\": \"Facebook\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"iOS\\/Facebook\"],\n\t\"developer_notes\": \"Spotted using a Facebook-exclusive audio feed. Distinct from the above, since it's the actual podcast player.\",\n\t\"description\": \"The Facebook app's podcast player on iOS.\"\n}, {\n\t\"user_agents\": [\"^FB4A\\/Facebook\"],\n\t\"app\": \"Facebook\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"examples\": [\"FB4A\\/Facebook\"],\n\t\"developer_notes\": \"Spotted using a Facebook-exclusive audio feed\",\n\t\"description\": \"The Facebook app's podcast player on Android.\"\n}, {\n\t\"user_agents\": [\"^feedly\\/\"],\n\t\"app\": \"Feedly\",\n\t\"examples\": [\"feedly\\/81.0.1 CFNetwork\\/1206 Darwin\\/20.1.0\"],\n\t\"description\": \"An RSS reader\"\n}, {\n\t\"user_agents\": [\"Linux.*Firefox\\/\"],\n\t\"app\": \"Firefox\",\n\t\"device\": \"pc\",\n\t\"os\": \"linux\"\n}, {\n\t\"user_agents\": [\"Mac OS X.*Firefox\\/\"],\n\t\"app\": \"Firefox\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"Windows.*Firefox\\/\"],\n\t\"app\": \"Firefox\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Mozilla\\/5.0 (Windows NT 10.0; Win64; x64; rv:69.0) Gecko\\/20100101 Firefox\\/69.0\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"Android.*(Focus|Firefox)\\/\"],\n\t\"app\": \"Firefox\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"iPhone.*Focus\\/\"],\n\t\"app\": \"Firefox\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iPad.*Focus\\/\"],\n\t\"app\": \"Firefox\",\n\t\"device\": \"tablet\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Lavf\\/\"],\n\t\"developer_notes\": \"ffmpeg is a library used within TuneIn, VLC, ffmpeg and other programs. This is the default useragent for the ffmpeg library. Since it's a library, not an app by itself, we don't add its name here.\"\n}, {\n\t\"user_agents\": [\"^MAC \"],\n\t\"app\": \"Flash\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"^WIN \"],\n\t\"app\": \"Flash\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^foobar2000\\/\"],\n\t\"app\": \"foobar2000\",\n\t\"examples\": [\"foobar2000\\/1.x\"],\n\t\"info_url\": \"https:\\/\\/www.foobar2000.org\\/\"\n}, {\n\t\"user_agents\": [\"^Fountain.+(iOS|ios)\"],\n\t\"app\": \"Fountain\",\n\t\"examples\": [\"Fountain\\/0.2.6 iOS https:\\/\\/www.fountain.fm'\", \"Fountain\\/0.3.8 ios https:\\/\\/www.fountain.fm\"],\n\t\"info_url\": \"https:\\/\\/www.fountain.fm\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Fountain.+(Android|android)\"],\n\t\"app\": \"Fountain\",\n\t\"examples\": [\"Fountain\\/0.2.6 Android https:\\/\\/www.fountain.fm'\", \"Fountain\\/0.3.13 android https:\\/\\/www.fountain.fm\"],\n\t\"info_url\": \"https:\\/\\/www.fountain.fm\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^fyyd-poll\"],\n\t\"app\": \"Fyyd\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Garmin Forerunner\"],\n\t\"app\": \"Garmin Forerunner\",\n\t\"developer_notes\": \"This uses Spotify to transfer audio to compatible watches\",\n\t\"device\": \"watch\",\n\t\"examples\": [\"Garmin Forerunner 245 Music\\/11.60\"]\n}, {\n\t\"user_agents\": [\"^Garmin fenix 5X Plus\"],\n\t\"app\": \"Garmin fenix 5X\",\n\t\"developer_notes\": \"This uses Spotify to transfer audio to compatible watches\",\n\t\"device\": \"watch\",\n\t\"examples\": [\"Garmin fenix 5X Plus/18.0\"]\n}, {\n\t\"user_agents\": [\"^Go-http-client\"],\n\t\"developer_notes\": \"This has been seen being used by a TuneIn client, and may be within WinAMP code.\",\n\t\"examples\": [\"Go-http-client\\/2.0\"]\n}, {\n\t\"user_agents\": [\"Goodpods(.)Android \\/\"],\n\t\"examples\": [\"Goodpods.Android \\/ 2.2.2\",\"Goodpods.Android / 3.2.9\"],\n\t\"app\": \"Goodpods\",\n\t\"os\": \"android\",\n\t\"description\": \"The social podcasting app\",\n\t\"info_url\": \"https:\\/\\/www.goodpods.com\\/\",\n\t\"svg\": \"goodpods.svg\"\n}, {\n\t\"user_agents\": [\"Goodpods.[iI]OS \\/ \\\\d+\\\\.\\\\d+\\\\.\\\\d+\"],\n\t\"examples\": [\"Goodpods.iOS \\/ 2.2.2\"],\n\t\"app\": \"Goodpods\",\n\t\"os\": \"ios\",\n\t\"description\": \"The social podcasting app\",\n\t\"info_url\": \"https:\\/\\/www.goodpods.com\\/\",\n\t\"svg\": \"goodpods.svg\"\n}, {\n\t\"user_agents\": [\"Goodpods\\/\\\\d+\\\\.\\\\d+\"],\n\t\"examples\": [\"Goodpods\\/2.2\"],\n\t\"app\": \"Goodpods\",\n\t\"os\": \"linux\",\n\t\"bot\": true,\n\t\"description\": \"The social podcasting app\",\n\t\"info_url\": \"https:\\/\\/www.goodpods.com\\/\",\n\t\"svg\": \"goodpods.svg\",\n\t\"developer_notes\": \"RSS scraper \\/ podcast verifier. Contact hello at goodpods dot com.\"\n}, {\n\t\"user_agents\": [\"Goodpods\\/1 CFNetwork\"],\n\t\"examples\": [\"Goodpods\\/1 CFNetwork\\/1329 Darwin\\/21.3.0\"],\n\t\"app\": \"Goodpods\",\n\t\"os\": \"ios\",\n\t\"description\": \"The social podcasting app\",\n\t\"info_url\": \"https:\\/\\/www.goodpods.com\\/\",\n\t\"svg\": \"goodpods.svg\"\n}, {\n\t\"user_agents\": [\"^Gumball\"],\n\t\"examples\": [\"Gumball.fm Analytics Prefix Checker\"],\n\t\"app\": \"Gumball\",\n\t\"bot\": true,\n\t\"description\": \"An attribution service (known as Gumshoe) from the Gumball network\"\n}, {\n\t\"user_agents\": [\"Googlebot\\/\", \"Googlebot-Video\\/\", \"Googlebot-Image\\/\"],\n\t\"examples\": [\"Mozilla\\/5.0 (Linux; Android 6.0.1; Nexus 5X Build\\/MMB29P) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/86.0.4240.96 Mobile Safari\\/537.36 (compatible; Googlebot\\/2.1; http:\\/\\/www.google.com\\/bot.html)\", \"Googlebot-Image\\/1.0\"],\n\t\"description\": \"Google's search bots\",\n\t\"app\": \"Googlebot\",\n\t\"info_url\": \"http:\\/\\/www.google.com\\/bot.html\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"Google-Podcast\"],\n\t\"bot\": true,\n\t\"app\": \"Google Podcasts Manager\"\n}, {\n\t\"user_agents\": [\"^Google-Speech-Actions\"],\n\t\"app\": \"Google Assistant\",\n\t\"device\": \"speaker\",\n\t\"developer_notes\": \"This is audio downloaded as a result of a Google Assistant voice action. It's unlikely to appear in podcast host logs, since voice actions can only use up to 120 seconds of audio.\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/cloud.google.com\\/text-to-speech\\/docs\\/ssml\"\n}, {\n\t\"user_agents\": [\"GoogleChirp\"],\n\t\"app\": \"Google Podcasts\",\n\t\"device\": \"speaker\",\n\t\"description\": \"Google Podcasts on smart speakers\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^GooglePodcasts\\/(iPhone|iPad|iPod touch|!Android).*GSA\\/\", \"^Podcasts$\", \"^GooglePodcasts\\/.*(iPhone|iPad|iPod touch)\"],\n\t\"app\": \"Google Podcasts\",\n\t\"description\": \"Google Podcasts on iOS\",\n\t\"examples\": [\"GooglePodcasts\\/2.0.2 iPod_touch\\/13.4.1 hw\\/iPod9_1\", \"GooglePodcasts\\/2.0.10 iPhone\\/14.6 hw\\/iPhone12_1\", \"GooglePodcasts\\/2.0.10 iPhone\\/14.6 hw\\/iPhone13_3\", \"Mozilla\\/5.0 (iPhone; CPU iPhone OS 13_4 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) GSA\\/107.0.310639584 Mobile\\/15E148 Safari\\/604.1\", \"Mozilla\\/5.0 (iPod touch; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) GSA\\/107.0.310639584 Mobile\\/15E148 Safari\\/601.1\"],\n\t\"developer_notes\": \"'GooglePodcasts' is the iOS app, while (?:(?:iPhone|iPad|iPod touch);.+)?GSA\\/ is used in the Google app when searching and playing podcasts. The first useragent was simply 'Podcasts'.\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"(Android).*GSA\\/\",\"^GSA\\/\"],\n\t\"app\": \"Google Podcasts\",\n\t\"os\": \"android\",\n\t\"description\": \"Google Podcasts on Android (the app or player)\",\n\t\"examples\": [\"GSA/13.38.11.26.arm64\",\"Mozilla\\/5.0 (Linux; Android 10; Pixel 3a Build\\/QQ2A.200305.002; wv) AppleWebKit\\/537.36 (KHTML, like Gecko) Version\\/4.0 Chrome\\/80.0.3987.149 Mobile Safari\\/537.36 GSA\\/11.2.7.21.arm64\", \"Mozilla\\/5.0 (Linux; Android 10; SM-G986U Build\\/QP1A.190711.020; wv) AppleWebKit\\/537.36 (KHTML, like Gecko) Version\\/4.0 Chrome\\/86.0.4240.75 Mobile Safari\\/537.36 GSA\\/11.31.12.21.arm64\"],\n\t\"developer_notes\": \"*GSA\\/ is used in the Google app when searching and playing podcasts, and in the Google Podcasts app\"\n}, {\n\t\"user_agents\": [\"Linux; Android.*SM-T350\"],\n\t\"device\": \"tablet\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^(?!Podverse).*Android.*Chrome\\/(?!.*(Googlebot|CrKey|GSA|Edge|EdgA\\/))\"],\n\t\"app\": \"Google Chrome\",\n\t\"os\": \"android\",\n\t\"developer_notes\": \"This won't match Googlebot, a Chromecast device, Google speaker or Google app, or the Podverse app\"\n}, {\n\t\"user_agents\": [\"CrOS.*Chrome\\/\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"pc\",\n\t\"os\": \"chromeos\"\n}, {\n\t\"user_agents\": [\"Linux(?!.*(Android)).*Chrome\\/(?!.*(CrKey|GSA\\/))\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"pc\",\n\t\"os\": \"linux\",\n\t\"developer_notes\": \"This won't match an Android device, Chromecast device, Google speaker or Google app\"\n}, {\n\t\"user_agents\": [\"Mac OS X.*Chrome\\/(?!.*(Spreaker\\/|OPR\\/))\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Mozilla\\/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/54.0.2840.71 Safari\\/537.36\"],\n\t\"os\": \"macos\",\n\t\"developer_notes\": \"This won't match the Spreaker app\"\n}, {\n\t\"user_agents\": [\"Windows.*Chrome\\/(?!.*(OPR|Edg|Electron|PodFriend\\/))\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Mozilla\\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/77.0.3865.120 Safari\\/537.36\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"iPad.*CriOS\\/\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"tablet\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iPhone.*CriOS\\/\"],\n\t\"app\": \"Google Chrome\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iPhone.+GSA\\/\\\\d+\"],\n\t\"app\": \"Google Search App\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Mozilla\\/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) GSA\\/166.0.381336632 Mobile\\/15E148 Safari\\/604.1\"],\n\t\"description\": \"The Google Search App on iPhone.\"\n}, {\n\t\"user_agents\": [\"^gPodder\\/.*Windows\", \"^gpodder\\\\.net\"],\n\t\"app\": \"gPodder\",\n\t\"os\": \"windows\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"gPodder\\/3.10.8 (+http:\\/\\/gpodder.org\\/) Windows\\/10\"]\n}, {\n\t\"user_agents\": [\"^GStreamer\"],\n\t\"device\": \"radio\"\n}, {\n\t\"user_agents\": [\"^GaanaAndroid-\"],\n\t\"app\": \"Gaana\",\n\t\"os\": \"android\",\n\t\"examples\": [\"GaanaAndroid-8.13.0\\/Dalvik\\/2.1.0 (Linux; U; Android 9; vivo 1906 Build\\/PKQ1.190616.001)\", \"GaanaAndroid-8.13.0\\/Dalvik\\/2.1.0 (Linux; U; Android 5.1; Micromax P701 Build\\/LMY47D)\"]\n}, {\n\t\"user_agents\": [\"^Gaana-iOS\"],\n\t\"app\": \"Gaana\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Gaana-iOS\"]\n}, {\n\t\"user_agents\": [\"^Guardian-iOSLive\\/\"],\n\t\"app\": \"Guardian\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"GuardianAndroidApp\\/\"],\n\t\"app\": \"Guardian\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^gvfs\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Himalaya\\/.+iPhone\"],\n\t\"app\": \"Himalaya\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Himalaya\\/2.4.41 (iPhone; iOS 14.0.1; Scale\\/3.00; CFNetwork; iPhone9,4)\", \"Himalaya\\/2.4.42 (iPhone; iOS 14.2; Scale\\/2.00; CFNetwork; iPhone8,1)\"],\n\t\"os\": \"ios\",\n\t\"description\": \"Himalaya is a podcast app\"\n}, {\n\t\"user_agents\": [\"^HyperCatcher\"],\n\t\"app\": \"HyperCatcher\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"HyperCatcher\\/1\"],\n\t\"os\": \"ios\",\n\t\"description\": \"Newsletters and podcasts together!\"\n}, {\n\t\"user_agents\": [\"^iCatcher\"],\n\t\"app\": \"iCatcher\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^iHeartRadio\\/.*Android\"],\n\t\"app\": \"iHeartRadio\",\n\t\"os\": \"android\",\n\t\"examples\": [\"iHeartRadio\\/9.19.0 (Android 10; SM-G960U Build\\/QP1A.190711.020)\", \"iHeartRadio\\/9.19.0 (Android 9; SM-G950U Build\\/PPR1.180610.011)\"],\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.clearchannel.iheartradio.controller\"\n}, {\n\t\"user_agents\": [\"^iHeartRadio\\/.* CFNetwork\\/\", \"^iHeartRadio\\/.* iOS\"],\n\t\"app\": \"iHeartRadio\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"iHeartRadio\\/2020052002 CFNetwork\\/1125.2 Darwin\\/19.4.0\", \"iHeartRadio\\/9.20.0 (iPhone; iOS 13.4.1; iPhone11,8)\", \"iHeartRadio\\/9.20.0 (iPad; iOS 13.4.1; iPad6,12)\", \"iHeartRadio\\/9.7.0 (iPod touch; iOS 12.4.5; iPod7,1)\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/iheart-radio-music-podcasts\\/id290638154\"\n}, {\n\t\"user_agents\": [\"MSIE \"],\n\t\"app\": \"Internet Explorer\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident\\/6.0)\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"iVoox\"],\n\t\"app\": \"iVoox\",\n\t\"info_url\": \"https:\\/\\/www.ivoox.com\\/\"\n}, {\n\t\"user_agents\": [\"iVooxApp.*iPhone\"],\n\t\"app\": \"iVoox\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"iVooxApp/3.85 (iPhone; iOS; iOS 15.6.1; d:16910714; 113F)\"],\n\t\"info_url\": \"https:\\/\\/www.ivoox.com\\/\"\n}, {\n\t\"user_agents\": [\"ivooxApp.*Android\", \"FileDownloader\\/\"],\n\t\"app\": \"iVoox\",\n\t\"os\": \"android\",\n\t\"examples\": [\"ivooxApp\\/2.281.428_428 (G8341; Android 9; d:420031; E5A4)\"],\n\t\"info_url\": \"https:\\/\\/www.ivoox.com\\/\"\n}, {\n\t\"user_agents\": [\"iTMS\", \"itunesstored\"],\n\t\"app\": \"Apple Podcasts automated checks\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^iTunes\\/.+Mac OS\", \"^iTunes\\/.+OS X\"],\n\t\"examples\": [\"iTunes\\/10.6.3 (Macintosh; Intel Mac OS X 10.5.8) AppleWebKit\\/534.50.2\"],\n\t\"app\": \"iTunes\",\n\t\"device\": \"pc\",\n\t\"info_url\": \"https:\\/\\/www.apple.com\\/itunes\\/\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"^iTunes\\/.+Windows\"],\n\t\"examples\": [\"iTunes\\/11.4 (Windows; Microsoft Windows 7 x64 Home Premium Edition (Build 7600)) AppleWebKit\\/7600.1017.0.24\", \"iTunes\\/12.10.9 (Windows; Microsoft Windows 10 x64 Home Premium Edition (Build 19041); x64) AppleWebKit\\/7609.3005.1003.3\"],\n\t\"app\": \"iTunes\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^iTunes\\/4\"],\n\t\"device\": \"speaker\"\n}, {\n\t\"user_agents\": [\"J. River Internet Reader\"],\n\t\"examples\": [\"Microsoft-Windows-XP\\/2002, UPnP\\/1.1, J. River Internet Reader\\/2.0 (compatible; Windows-Media-Player\\/10)\"],\n\t\"app\": \"JRiver Media Center\",\n\t\"device\": \"pc\",\n\t\"info_url\": \"https:\\/\\/www.jriver.com\\/\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\".*KAIOS\\/(?!.*(PodKast))\"],\n\t\"app\": \"KAIOS podcast app\",\n\t\"device\": \"phone\",\n\t\"os\": \"kaios\",\n\t\"info_url\": \"https:\\/\\/kaiostech.com\",\n\t\"examples\": [\"Mozilla\\/5.0 (Mobile; LYF\\/F271i\\/LYF_F271i-000-01-20-101019; Android; rv:48.0) Gecko\\/48.0 Firefox\\/48.0 KAIOS\\/2.5\"],\n\t\"developer_notes\": \"This is a standard useragent for KaiOS, the cut-down operating system for mobile phones in developing countries. Watch out - it may also contain Android.\"\n}, {\n\t\"user_agents\": [\".*PodLP\\/\"],\n\t\"app\": \"PodLP podcast app for KaiOS\",\n\t\"device\": \"phone\",\n\t\"os\": \"kaios\",\n\t\"info_url\": \"https:\\/\\/podlp.com\",\n\t\"examples\": [\"Mozilla\\/5.0 (Mobile; LYF\\/F271i\\/LYF_F271i-000-01-20-101019; Android; rv:48.0) Gecko\\/48.0 Firefox\\/48.0 KAIOS\\/2.5 PodLP\\/1.3.2.0\"],\n\t\"description\": \"PodLP is the first podcast app available for KaiOS smart feature phones on the KaiStore.\",\n\t\"developer_notes\": \"Introduced in version v1.2.0.0 for limited content (downloads); available for all content after v1.3.0.0\"\n}, {\n\t\"user_agents\": [\".*PodKast$\"],\n\t\"app\": \"PodKast app\",\n\t\"device\": \"phone\",\n\t\"os\": \"kaios\",\n\t\"examples\": [\"Mozilla\\/5.0 (Mobile; M571M3; rv:48.0) Gecko\\/48.0 Firefox\\/48.0 KAIOS\\/2.5.1.2 PodKast\", \"KaiOS Downloader PodKast\"],\n\t\"description\": \"PodKast is a podcast app available for KaiOS smartphones\"\n}, {\n\t\"user_agents\": [\"^Laughable.+iOS\"],\n\t\"app\": \"Laughable\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^lesindesradios$\"],\n\t\"app\": \"Les Ind\\u00e9s Radios\",\n\t\"os\": \"ios\",\n\t\"device\": \"phone\",\n\t\"description\": \"Les Ind\\u00e9s Radios is a radio app, available on multiple devices and OSs\",\n\t\"info_url\": \"https:\\/\\/www.lesindesradios.fr\\/\",\n\t\"examples\": [\"lesindesradios\"]\n}, {\n\t\"user_agents\": [\"^lesindesradios\\/.*\\\\(Linux;Android\"],\n\t\"app\": \"Les Ind\\u00e9s Radios\",\n\t\"os\": \"android\",\n\t\"device\": \"phone\",\n\t\"description\": \"Les Ind\\u00e9s Radios is a radio app, available on multiple devices and OSs\",\n\t\"info_url\": \"https:\\/\\/www.lesindesradios.fr\\/\",\n\t\"examples\": [\"lesindesradios\\/9.1.0 (Linux;Android 8.0.0) ExoPlayerLib\\/2.9.2\", \"lesindesradios\\/9.1.0 (Linux;Android 11) ExoPlayerLib\\/2.9.2\"]\n}, {\n\t\"user_agents\": [\"^com.jio.media.jiobeats\", \"^com.saavn.android\", \"^saavn\"],\n\t\"app\": \"JioSaavn\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/www.jiosaavn.com\\/\",\n\t\"description\": \"A music streaming and podcast app from India. Earn Your Happy!\",\n\t\"developer_notes\": \"The user-agent will start with one of the above strings followed by the app version and player version.\",\n\t\"examples\": [\"com.jio.media.jiobeats\\/7.3.1 (Linux;Android 8.1.0) ExoPlayerLib\\/2.11.4\"]\n}, {\n\t\"user_agents\": [\"^lamarr-iOS\", \"^TheEconomist-Lamarr-ios\"],\n\t\"app\": \"The Economist\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"lamarr-iOS-2.20.3-116\", \"TheEconomist-Lamarr-ios-2.22.2-12002\"]\n}, {\n\t\"user_agents\": [\"^lamarr-android\", \"^TheEconomist-Lamarr-android\"],\n\t\"app\": \"The Economist\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"examples\": [\"lamarr-android-2.18.1-21810\", \"TheEconomist-Lamarr-android-2.22.2-12002\"]\n}, {\n\t\"user_agents\": [\"LG Player\"],\n\t\"device\": \"phone\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^libwww-perl\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"iPhone.+\\\\[LinkedInApp\\\\]\"],\n\t\"app\": \"LinkedIn\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Mozilla\\/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) Mobile\\/15E148 [LinkedInApp]\"],\n\t\"description\": \"The LinkedIn app's built-in browser on iPhones.\"\n}, {\n\t\"user_agents\": [\"Listen5\"],\n\t\"app\": \"Listen5\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Lisny\"],\n\t\"app\": \"Lisny\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Lisny\"],\n\t\"info_url\": \"https:\\/\\/www.lisny.com\",\n\t\"description\": \"Lisny is a fast, beautiful and fun listening experience.\"\n}, {\n\t\"user_agents\": [\"LivelapBot\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Luminary\\/.+Android\"],\n\t\"app\": \"Luminary\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Luminary\\/.+iOS\"],\n\t\"app\": \"Luminary\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^MajelanApp\"],\n\t\"app\": \"Majelan\"\n}, {\n\t\"user_agents\": [\"^Mechanize\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^MediaMonkey\"],\n\t\"app\": \"MediaMonkey\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^Miro\\/.+Windows\"],\n\t\"app\": \"Miro\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Miro\\/6.0 (http:\\/\\/www.getmiro.com\\/; Windows post2008Server x86)\"],\n\t\"info_url\": \"http:\\/\\/www.getmiro.com\\/\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\".*MJ12bot\"],\n\t\"app\": \"MJ12bot\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; MJ12bot\\/v1.4.8; http:\\/\\/mj12bot.com\\/)\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^mpv 0\\\\.\"],\n\t\"app\": \"mpv\",\n\t\"info_url\": \"https:\\/\\/mpv.io\\/\"\n}, {\n\t\"user_agents\": [\"^MusicBee\"],\n\t\"app\": \"MusicBee\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"MusicBee\"],\n\t\"info_url\": \"https:\\/\\/getmusicbee.com\\/\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\".*Neevabot\"],\n\t\"app\": \"Neevabot\",\n\t\"bot\": true,\n\t\"info_url\": \"https:\\/\\/neeva.com\\/neevabot\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; Neevabot\\/1.0; https:\\/\\/neeva.com\\/neevabot)\"]\n}, {\n\t\"user_agents\": [\"^NPR%20One\\/\"],\n\t\"app\": \"NPR One\",\n\t\"examples\": [\"NPR%20One\\/234 CFNetwork\\/1197 Darwin\\/20.0.0\"]\n}, {\n\t\"user_agents\": [\"^NPROneAndroid\"],\n\t\"app\": \"NPR One\",\n\t\"os\": \"android\",\n\t\"examples\": [\"NPROneAndroid\"]\n}, {\n\t\"user_agents\": [\"NRC Audio\\/.*Android\"],\n\t\"examples\": [\"NRC Audio/2.0.0 (nl.nrc.audio; build:29; Android 12; Sdk:31; Manufacturer:samsung; Model: SM-G975F) OkHttp/4.9.3\"],\n\t\"description\": \"NRC Audio\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^OkDownload\\/\"]\n}, {\n\t\"user_agents\": [\"okhttp\"],\n\t\"examples\": [\"okhttp\\/3.11.0\"]\n}, {\n\t\"user_agents\": [\"Opera\\/.*Android;\"],\n\t\"app\": \"Opera\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Opera\\/.*\\\\(Linux\"],\n\t\"app\": \"Opera\",\n\t\"device\": \"pc\",\n\t\"os\": \"linux\"\n}, {\n\t\"user_agents\": [\"Opera\\/.*\\\\(Macintosh\", \"Macintosh.*OPR\\/\"],\n\t\"app\": \"Opera\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\",\n\t\"examples\": [\"Mozilla\\/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/86.0.4240.111 Safari\\/537.36 OPR\\/72.0.3815.186\"]\n}, {\n\t\"user_agents\": [\"Opera\\/.*\\\\(Windows\", \"Windows.*OPR\\/\"],\n\t\"app\": \"Opera\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^MauiBot\"],\n\t\"app\": \"MauiBot\",\n\t\"bot\": true,\n\t\"examples\": [\"MauiBot (crawler.feedback dc@gmail.com)\"]\n}, {\n\t\"user_agents\": [\"^Overcast\\/.*iOS\"],\n\t\"app\": \"Overcast\",\n\t\"examples\": [\"Overcast\\/3.0 (+http:\\/\\/overcast.fm\\/; iOS podcast app)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Overcast.*Apple Watch\"],\n\t\"app\": \"Overcast\",\n\t\"examples\": [\"Overcast ( http:\\/\\/overcast.fm\\/; Apple Watch podcast app)\"],\n\t\"os\": \"watchos\",\n\t\"device\": \"watch\"\n}, {\n\t\"user_agents\": [\"^Overcast\\/1.0 Podcast Sync\"],\n\t\"app\": \"Overcast feed parser\",\n\t\"examples\": [\"Overcast\\/1.0 Podcast Sync\"],\n\t\"developer_notes\": \"Marco Arment says: when a new episode is detected, the servers fetch a copy of it to seed these values with an initial set of data to make the privacy screen more accurate.\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^PandoraRSSCrawler\"],\n\t\"bot\": true,\n\t\"app\": \"Pandora RSS crawler\"\n}, {\n\t\"user_agents\": [\"^Pandora.+Android\"],\n\t\"app\": \"Pandora\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Pandora\\/2009.2 Android\\/7.1.1 gteslteatt (ExoPlayerLib1.5.14.1)\"]\n}, {\n\t\"user_agents\": [\"iPhone.+Pandora\\/\"],\n\t\"app\": \"Pandora\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Mozilla\\/5.0 (iPhone; CPU iPhone OS 12_4_6 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) Mobile\\/15E148 Pandora\\/2009.2\"]\n}, {\n\t\"user_agents\": [\"PaperLiBot\\/\"],\n\t\"app\": \"PaperLi\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; PaperLiBot\\/2.1; https:\\/\\/support.paper.li\\/entries\\/20023257-what-is-paper-li)\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Player FM\",\"^Player%20FM\"],\n\t\"app\": \"Player FM\",\n    \"examples\": [\"Player%20FM/588 CFNetwork/1121.2.2 Darwin/19.2.0\"]\n}, {\n\t\"user_agents\": [\"^Pingdom\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Pocket Casts\", \"^PocketCasts\\/\"],\n\t\"app\": \"Pocket Casts\",\n\t\"examples\": [\"Pocket Casts\"],\n\t\"info_url\": \"https:\\/\\/www.pocketcasts.com\\/\",\n\t\"description\": \"A podcast app and web player\",\n\t\"developer_notes\": \"'PocketCasts' is a feed parser; 'Pocket Casts' is the app. There is also a web player.\",\n\t\"svg\": \"pocketcasts.svg\"\n}, {\n\t\"user_agents\": [\"^Podcast.*Addict\\/\"],\n\t\"app\": \"PodcastAddict\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"PodcastAddict\\/v2 - Dalvik\\/2.1.0 (Linux; U; Android 9; SM-N950U Build\\/PPR1.180610.011)\", \"PodcastAddict\\/v5 ( https:\\/\\/podcastaddict.com\\/; Android podcast app)\"],\n\t\"os\": \"android\"\n}, {\n    \"user_agents\": [\"^ThePodcastApp.*iPhone\"],\n    \"app\": \"The Podcast App\",\n    \"os\": \"ios\",\n    \"device\": \"phone\",\n    \"examples\": [\"ThePodcastApp/6.23.0 (iPhone; iOS 15.6.1; ) player (build 6272; +https://podcast.app/)\"]\n}, {\n\t\"user_agents\": [\"iOS.*The Podcast App\\/\", \"com.evolve.podcast\\/\", \"^ThePodcastApp(?!.*(iPhone))\"],\n\t\"app\": \"The Podcast App\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"ThePodcastApp/6.28.1 (iPhone; iOS 16.0.2; ) player (build 6391; +https://podcast.app/)\",\"podcast\\/2358 iOS\\/Version 13.5.1 (Build 017F80) The Podcast App\\/3.22.1\", \"com.evolve.podcast\\/3.22.1 (iPhone; ) (build 2358, iOS 13.5.1)\"],\n\t\"developer_notes\": \"The com.evolve version of the useragent is an error, and has been reported to the developers as a bug. Caution: the beginning of their main useragent is similar to Google Podcasts.\"\n}, {\n\t\"user_agents\": [\"^PodcastGuru\"],\n\t\"app\": \"Podcast Guru\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/podcastguru.io\\/\",\n\t\"description\": \"Podcast Guru is the simple and free podcast player\"\n}, {\n\t\"user_agents\": [\"^PodcastRepublic.+Android\"],\n\t\"app\": \"PodcastRepublic\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"PodcastRepublic\\/18.0 (Linux; U; Android 10;blueline\\/QP1A.190711.020.C3)\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"podCloud\"],\n\t\"app\": \"PodCloud\",\n\t\"description\": \"Le podcast, simplement. A French-language web-based podcast player.\",\n\t\"bot\": true,\n\t\"developer_notes\": \"This useragent is a bot, doing feed updates and downloading media files. It was observed every six hours. User plays will have a standard browser useragent with a referer of https:\\/\\/podcloud.fr\\/ \",\n\t\"info_url\": \"https:\\/\\/podcloud.fr\"\n}, {\n\t\"user_agents\": [\"^Podcoin\"],\n\t\"app\": \"Podcoin\"\n}, {\n\t\"user_agents\": [\"^PodCruncher\\/.* CFNetwork\\/\"],\n\t\"app\": \"PodCruncher\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"PodCruncher\\/3.7.1 CFNetwork\\/1125.2 Darwin\\/19.4.0\", \"PodCruncher\\/3.7.1 CFNetwork\\/978.0.7 Darwin\\/18.7.0\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/podcruncher-podcast-player\\/id421894356\"\n}, {\n\t\"user_agents\": [\"^Podbean\\/Android App\", \"^Podbean\\/Android generic\"],\n\t\"app\": \"Podbean\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Podbean\\/Android App 7.6.4 (http:\\/\\/podbean.com),1927526fe23b5acf535b3e91b64cee95\", \"Podbean\\/Android App 8.1.5 (http:\\/\\/podbean.com),4f6852f59091d32475ef75a53325a4fe\", \"Podbean\\/Android generic 1.1.2 (http:\\/\\/podbean.com),9376c517335ded9a716022cc1f15c884\"],\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.podbean.app.podcast\"\n}, {\n\t\"user_agents\": [\"^Podbean\\/iOS\"],\n\t\"app\": \"Podbean\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Podbean\\/iOS (http:\\/\\/podbean.com) 5.2.0 - 19c4ff292bd09cd2ccbad22cc6755a45\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/podbean-podcast-app-player\\/id973361050\"\n}, {\n\t\"user_agents\": [\"podfollowbot\\/\"],\n\t\"app\": \"Podfollow\",\n\t\"examples\": [\"Mozilla\\/5.0 https:\\/\\/podfollow.com\\/crawling podfollowbot\\/1.0\"],\n\t\"info_url\": \"https:\\/\\/podfollow.com\",\n\t\"description\": \"Podfollow, a service to help link to your podcast\",\n\t\"bot\": true\n}, {\n  \t\"user_agents\": [\"PodderBot\\/\"],\n  \t\"examples\": [\"PodderBot\\/1.0\"],\n  \t\"app\": \"PodderBot\",\n  \t\"bot\": true,\n  \t\"description\": \"PodderApp bot\",\n  \t\"info_url\": \"https:\\/\\/www.podderapp.com\\/\",\n  \t\"developer_notes\": \"PodderApp bot for RSS fetching / verification\"\n}, {\n\t\"user_agents\": [\"^Podfriend\"],\n\t\"app\": \"Podfriend\",\n\t\"examples\": [\"Podfriend\\/1.0 Mozilla\\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\\/537.36 (KHTML, like Gecko) PodFriend\\/0.7.11 Chrome\\/83.0.4103.122 Electron\\/9.2.0 Safari\\/537.36\"],\n\t\"info_url\": \"https:\\/\\/podfriend.com\",\n\t\"description\": \"Podfriend Electron app\"\n}, {\n\t\"user_agents\": [\"^Podhero\", \"^Swoot\\/\"],\n\t\"app\": \"Podhero\",\n\t\"examples\": [\"Podhero%20Alpha\\/4373 CFNetwork\\/1197 Darwin\\/20.0.0\"],\n\t\"info_url\": \"https:\\/\\/podhero.com\",\n\t\"description\": \"Podhero app on iOS and Android.\"\n}, {\n\t\"user_agents\": [\"^Podkicker\"],\n\t\"app\": \"Podkicker Pro\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"PodLink\"],\n\t\"app\": \"PodLink\",\n\t\"info_url\": \"https:\\/\\/pod.link\\/faq\\/crawler\"\n}, {\n\t\"user_agents\": [\"^PodMN\\/Android\"],\n\t\"description\": \"Minnesota Podcasts Live Here\",\n\t\"examples\": [\"PodMN\\/Android 1.2.6 (Android 7.1.1; SM-J510FN Build\\/NMF26X)\"],\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.podmn.app\",\n\t\"app\": \"PodMN\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"svg\": \"podmn.svg\"\n}, {\n\t\"user_agents\": [\"^PodMN\\/iOS\"],\n\t\"description\": \"Minnesota Podcasts Live Here\",\n\t\"examples\": [\"PodMN\\/iOS 1.2.6 (iPhone XR\\/13.6.1)\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/podmn\\/id1464935818\",\n\t\"app\": \"PodMN\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"svg\": \"podmn.svg\"\n}, {\n\t\"user_agents\": [\"PodnewsBot\"],\n\t\"app\": \"Podnews\",\n\t\"bot\": true,\n\t\"description\": \"Podnews runs a number of bots to read and test RSS and audio files\",\n\t\"info_url\": \"http:\\/\\/podnews.net\"\n}, {\n\t\"user_agents\": [\"podnods-crawler\", \"podnods\"],\n\t\"app\": \"Podnods\",\n\t\"bot\": true,\n\t\"description\": \"Podnods is a podcast discovery site. This user agent is for crawling podcast data.\",\n\t\"info_url\": \"https:\\/\\/podnods.com\\/about\"\n}, {\n\t\"user_agents\": [\"^Podurama\"],\n\t\"app\": \"Podurama\",\n\t\"description\": \"Best free cross-platform podcast app\",\n\t\"examples\": [\"Podurama/4.0.8.1 CFNetwork/1390 Darwin/22.0.0\"],\t\n\t\"info_url\": \"https:\\/\\/podurama.com\"\n}, {\n\t\"user_agents\": [\"podnods-player\"],\n\t\"app\": \"Podnods\",\n\t\"description\": \"Podnods is a podcast discovery site. This user agent is for users to sample and play podcasts.\",\n\t\"info_url\": \"https:\\/\\/podnods.com\\/about\"\n}, {\n\t\"user_agents\": [\"^Procast.+iOS\"],\n\t\"app\": \"Procast\",\n\t\"description\": \"Procast - The Podcast App\",\n\t\"info_url\": \"https:\\/\\/procast.coderocker.de\\/\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Podwatch-Pro Crawler\"],\n\t\"app\": \"PodwatchPro\",\n\t\"bot\": true,\n\t\"description\": \"PodwatchPro is a podcast analytics software. This user agent is for crawling podcast data.\",\n\t\"info_url\": \"https:\\/\\/www.agma-mmc.de\\/media-analyse\\/ma-podcast\"\n}, {\n\t\"user_agents\": [\"Podyssey App\"],\n\t\"app\": \"Podyssey\",\n\t\"description\": \"Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.\",\n\t\"info_url\": \"https:\\/\\/podyssey.fm\"\n}, {\n\t\"user_agents\": [\"com.toysinboxes.Echo\"],\n\t\"app\": \"Podyssey\",\n\t\"description\": \"Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.\",\n\t\"info_url\": \"https:\\/\\/podyssey.fm\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Podopolo\"],\n\t\"app\": \"Podopolo\",\n\t\"description\": \"Listen with passion - connect with purpose\",\n\t\"info_url\": \"https:\\/\\/podopolo.com\\/\"\n}, {\n\t\"user_agents\": [\"fm.podyssey.podcasts\"],\n\t\"app\": \"Podyssey\",\n\t\"description\": \"Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.\",\n\t\"info_url\": \"https:\\/\\/podyssey.fm\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"python-requests\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^radio.de\\/app.+Android\"],\n\t\"app\": \"radio.de\",\n\t\"os\": \"android\",\n\t\"description\": \"Radio.de is a radio and podcast app in Germany\",\n\t\"info_url\": \"https:\\/\\/www.radio.de\\/\"\n}, {\n\t\"user_agents\": [\"^Radioplayer Android app\"],\n\t\"app\": \"RadioPlayer\",\n\t\"os\": \"android\",\n\t\"description\": \"Radioplayer is a radio and podcast app, with country-specific versions available in selected countries.\",\n\t\"info_url\": \"http:\\/\\/radioplayer.org\"\n}, {\n\t\"user_agents\": [\"^Radioplayer iOS app\"],\n\t\"app\": \"RadioPlayer\",\n\t\"os\": \"ios\",\n\t\"description\": \"Radioplayer is a radio and podcast app, with country-specific versions available in selected countries.\",\n\t\"info_url\": \"http:\\/\\/radioplayer.org\"\n}, {\n\t\"user_agents\": [\"^RadioPublic\\/android-\", \"^RadioPublic Android\"],\n\t\"app\": \"RadioPublic\",\n\t\"description\": \"RadioPublic\\u2019s free, easy to use podcast player makes listening to podcasts simple, enjoyable, and fun.\",\n\t\"examples\": [\"RadioPublic\\/android-2.2\"],\n\t\"info_url\": \"https:\\/\\/radiopublic.com\\/\",\n\t\"svg\": \"radiopublic.svg\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"RadioPublic iOS\", \"RadioPublic.+CFNetwork\", \"^RadioPublic\\/iOS\"],\n\t\"app\": \"RadioPublic\",\n\t\"description\": \"RadioPublic\\u2019s free, easy to use podcast player makes listening to podcasts simple, enjoyable, and fun.\",\n\t\"examples\": [\"RadioPublic\\/iOS-2.0\"],\n\t\"info_url\": \"https:\\/\\/radiopublic.com\\/\",\n\t\"svg\": \"radiopublic.svg\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Repod\\/.+iOS\"],\n\t\"app\": \"Repod\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Repod\\/2.9.0.363 CFNetwork\\/1240.0.4 Darwin\\/20.6.0 (iPhone\\/X iOS\\/14.7.1)\"],\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/repod.io\\/\",\n\t\"description\": \"Repod is a social podcast app that helps creators engage, montetization, and grow their community.\",\n\t\"svg\": \"repod.svg\"\n}, {\n\t\"user_agents\": [\"^Repod\\/.+Android\"],\n\t\"app\": \"Repod\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Repod\\/2.9.0.221 Mozilla\\/5.0 (Linux; Android 11; Pixel 3 Build\\/RQ3A.210905.001; wv) AppleWebKit\\/537.36 (KHTML, like Gecko) Version\\/4.0 Chrome\\/94.0.4606.85 Mobile Safari\\/537.36\"],\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/repod.io\\/\",\n\t\"description\": \"Repod is a social podcast app that helps creators engage, montetization, and grow their community.\",\n\t\"svg\": \"repod.svg\"\n}, {\n\t\"user_agents\": [\"request\\\\.js\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Roku\\/DVP-\"],\n\t\"device\": \"tv\",\n\t\"os\": \"roku\"\n}, {\n\t\"user_agents\": [\"^RSSRadio \\\\(\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^RSSRadio\"],\n\t\"app\": \"RSS Radio\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"RSSRadio7\\/9252 CFNetwork\\/1107.1 Darwin\\/19.0.0\", \"RSSRadio\\/9710\"],\n\t\"info_url\": \"http:\\/\\/rssrad.io\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Ruby\"],\n\t\"developer_notes\": \"The generic Ruby user-agent.\"\n}, {\n\t\"user_agents\": [\"sp-agent\"],\n\t\"app\": \"Samsung Free\",\n\t\"examples\": [\"sp-agent\"],\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/developer.samsung.com\\/podcasts\"\n}, {\n\t\"user_agents\": [\"SemrushBot\\/\"],\n\t\"app\": \"SEMrushBot\",\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; SemrushBot\\/6~bl; http:\\/\\/www.semrush.com\\/bot.html)\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"SerendeputyBot\\/\"],\n\t\"app\": \"Serendeputy\",\n\t\"examples\": [\"SerendeputyBot\\/0.8.6 (http:\\/\\/serendeputy.com\\/about\\/serendeputy-bot)\"],\n\t\"bot\": true,\n\t\"info_url\": \"https:\\/\\/serendeputy.com\\/about\\/serendeputy-bot\"\n}, {\n\t\"user_agents\": [\"^Spotify\\/.+Linux\"],\n\t\"app\": \"Spotify\",\n\t\"device\": \"pc\",\n\t\"os\": \"linux\"\n}, {\n\t\"user_agents\": [\"Macintosh.+Spotify\\/\", \"^Spotify\\/.+OSX\"],\n\t\"app\": \"Spotify\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"Windows.+Spotify\\/\", \"^Spotify\\/.+Win32\"],\n\t\"app\": \"Spotify\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^Spotify\\/.+Android\"],\n\t\"app\": \"Spotify\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Spotify\\/.+iOS\"],\n\t\"app\": \"Spotify\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Spotify\\/8.7.10 iOS\\/15.3.1 (iPhone13,2)\"]\n}, {\n\t\"user_agents\": [\"^Spotify\\/1.0$\"],\n\t\"app\": \"Spotify cache service\",\n\t\"bot\": true,\n\t\"examples\": [\"Spotify\\/1.0\"],\n\t\"developer_notes\": \"This useragent, currently simply Spotify\\/1.0, is used when retrieving the RSS and audio for Spotify's catalogue. It isn't used for passthru.\"\n}, {\n\t\"user_agents\": [\"Macintosh.*AppleWebKit(?!.*(Chrome\\/|GSA\\/)).*Safari\\/(?!.*(AdsBot\\/))\"],\n\t\"app\": \"Safari\",\n\t\"device\": \"pc\",\n\t\"os\": \"macos\",\n\t\"examples\": [\"Mozilla\\/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit\\/605.1.15 (KHTML, like Gecko) Version\\/14.0 Safari\\/605.1.15\"]\n}, {\n\t\"user_agents\": [\"Windows.*AppleWebKit(?!.*(Chrome\\/)).*Safari\\/\"],\n\t\"app\": \"Safari\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"iPhone.*AppleWebKit(?!.*(AdsBot|bingbot|CriOS|GSA\\/)).*Safari\\/\"],\n\t\"app\": \"Safari\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iPad.*AppleWebKit(?!.*(AdsBot|bingbot|CriOS|GSA\\/)).*Safari\\/\"],\n\t\"app\": \"Safari\",\n\t\"device\": \"tablet\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^Shadow\"],\n\t\"app\": \"Shadow\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/shadow\\/id940127690\"\n}, {\n\t\"user_agents\": [\"^Slack\\/\"],\n\t\"app\": \"Slack\"\n}, {\n\t\"user_agents\": [\"^Slackbot 1.0\"],\n\t\"app\": \"Slack\",\n\t\"bot\": true,\n\t\"examples\": [\"Slackbot 1.0 ( https:\\/\\/api.slack.com\\/robots)\"]\n}, {\n\t\"user_agents\": [\"^Snipd\\/\"],\n\t\"app\": \"Snipd\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/www.snipd.com\\/\",\n\t\"examples\": [\"Snipd\\/90 CFNetwork\\/1329 Darwin\\/21.3.0\"]\n}, {\n\t\"user_agents\": [\"^Subcast\"],\n\t\"app\": \"Subcast\"\n}, {\n\t\"user_agents\": [\"Sonnet\",\"^Simple Podcast Player\"],\n\t\"app\": \"Sonnet\",\n\t\"description\": \"Sonnet is a simple, easy to use podcast app aimed at new listeners\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/sonnet.fm\",\n    \"examples\": [\"Simple Podcast Player/1.8 (Linux;Android 12) ExoPlayerLib/2.10.1\"],\n\t\"svg\": \"sonnet.svg\"\n}, {\n\t\"user_agents\": [\"Sonos\"],\n\t\"app\": \"Sonos\",\n\t\"device\": \"speaker\",\n\t\"os\": \"sonos\"\n}, {\n\t\"user_agents\": [\"^Spreaker for Android\"],\n\t\"app\": \"Spreaker\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Spreaker\\/\"],\n\t\"app\": \"Spreaker\"\n}, {\n\t\"user_agents\": [\"support@dorada.co.uk\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Stitcher\\/Android\", \"^Stitcher Demo\\/\"],\n\t\"examples\": [\"Stitcher Demo\\/4.8.0 (Linux;Android 11) ExoPlayerLib\\/2.10.7\", \"Stitcher\\/Android\"],\n\t\"app\": \"Stitcher\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^AlexaMediaPlayer\\/Stitcher\"],\n\t\"app\": \"Stitcher\",\n\t\"device\": \"speaker\",\n\t\"os\": \"alexa\"\n}, {\n\t\"user_agents\": [\"^Stitcher\\/iOS\"],\n\t\"app\": \"Stitcher\",\n\t\"os\": \"ios\",\n\t\"device\": \"phone\"\n}, {\n\t\"user_agents\": [\"^StitcherBot\"],\n\t\"app\": \"Stitcher\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Storiyoh\\/\"],\n\t\"app\": \"Storiyoh\"\n}, {\n\t\"user_agents\": [\"^Swinsian\\/\"],\n\t\"app\": \"Swinsian\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"Swinsian\\/472 CFNetwork\\/978.0.7 Darwin\\/18.7.0 (x86_64)\"],\n\t\"info_url\": \"https:\\/\\/swinsian.com\\/\",\n\t\"os\": \"macos\"\n}, {\n\t\"user_agents\": [\"Timpibot\\/\"],\n\t\"app\": \"Timpi search crawler\",\n\t\"bot\": true,\n\t\"examples\": [\"Timpibot\\/0.8 ( http:\\/\\/www.timpi.io)\"]\n}, {\n\t\"user_agents\": [\"TrendsmapResolver\\/\"],\n\t\"app\": \"Trendsmap Resolver\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Trackable\\/\"],\n\t\"app\": \"Chartable\",\n\t\"info_url\": \"https:\\/\\/chartable.com\\/\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^TuneIn Radio\\/.*;Android\"],\n\t\"examples\": [\"TuneIn Radio\\/24.2 (Linux;Android 10) ExoPlayerLib\\/2.11.4\"],\n\t\"app\": \"TuneIn\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=tunein.player\"\n}, {\n\t\"user_agents\": [\"^TuneIn Radio Pro\\/.*;Android\"],\n\t\"examples\": [\"TuneIn Radio Pro\\/23.3.2 (Linux;Android 5.1.1) ExoPlayerLib\\/2.10.7\"],\n\t\"app\": \"TuneIn\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=radiotime.player\"\n}, {\n\t\"user_agents\": [\"^TuneIn(%20| )Radio\\/.*(CFNetwork\\/|iPhone)\"],\n\t\"examples\": [\"TuneIn Radio\\/1366 CFNetwork\\/1121.2.2 Darwin\\/19.3.0\", \"TuneIn Radio\\/18.1; iPhone12,8; iOS\\/13.4.1\", \"TuneIn%20Radio\\/1383 CFNetwork\\/1125.2 Darwin\\/19.4.0\"],\n\t\"app\": \"TuneIn\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/tunein-radio-live-news-music\\/id418987775\"\n}, {\n\t\"user_agents\": [\"^TuneIn(%20| )Radio(%20| )Pro\\/.*(CFNetwork\\/|iPhone)\"],\n\t\"examples\": [\"TuneIn Radio Pro/24.2.1; iPhone13,4; iOS/16.1\"],\n\t\"app\": \"TuneIn\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/tunein-pro-radio-sports\\/id319295332\"\n}, {\n\t\"user_agents\": [\"^TuneIn(?!.*(CFNetwork|Android|iPhone|iPad))\"],\n\t\"examples\": [\"TuneIn Radio\"],\n\t\"app\": \"TuneIn\",\n\t\"info_url\": \"https:\\/\\/tunein.com\\/\",\n\t\"developer_notes\": \"Other versions of this app use many other user agents.\"\n}, {\n\t\"user_agents\": [\"^TuneIn\\/\"],\n\t\"examples\": [\"Mozilla\\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\\/537.36 (KHTML, like Gecko) TuneIn\\/1.25.0 Chrome\\/69.0.3497.128 Electron\\/4.2.8 Safari\\/537.36\"],\n\t\"app\": \"TuneIn\",\n\t\"device\": \"pc\",\n\t\"info_url\": \"https:\\/\\/tunein.com\\/\",\n\t\"developer_notes\": \"A TuneIn Electron app\"\n}, {\n\t\"user_agents\": [\"^Twitterbot\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Typhoeus\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^VictorReader Stream\"],\n\t\"app\": \"VictorReader\",\n\t\"device\": \"speaker\",\n\t\"os\": \"victorreader\"\n}, {\n\t\"user_agents\": [\"^VLC\\/\\\\d\"],\n\t\"app\": \"VLC media player\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"VLC\\/3.0.8 LibVLC\\/3.0.8\"],\n\t\"info_url\": \"https:\\/\\/www.videolan.org\\/vlc\\/\"\n}, {\n\t\"user_agents\": [\"Wget\"],\n\t\"app\": \"Wget\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Winamp\"],\n\t\"app\": \"Winamp\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"WinampMPEG\\/2.7\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^NSPlayer\", \"^WMPlayer\\/\"],\n\t\"app\": \"Windows Media Player\",\n\t\"device\": \"pc\",\n\t\"examples\": [\"NSPlayer\\/12.00.18362.0418 WMFSDK\\/12.00.18362.0418\"],\n\t\"os\": \"windows\"\n}, {\n\t\"user_agents\": [\"^WordPress\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"iPhone.*XING\"],\n\t\"app\": \"XING\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Mozilla\\/5.0 (iPhone; CPU iPhone OS 13_6 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) ; iPhone\\/13.6 XING\\/8.15.2 ttt_webview_iosm\"],\n\t\"info_url\": \"https:\\/\\/www.xing.com\\/\",\n\t\"description\": \"German version of LinkedIn\"\n}, {\n\t\"user_agents\": [\"YandexBot\\/\"],\n\t\"app\": \"YandexBot\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^yapa\\/\"],\n\t\"app\": \"Yapa\"\n}, {\n\t\"user_agents\": [\"stagefright\\/\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Podimo\\/.*iOS\"],\n\t\"app\": \"Podimo\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Podimo\\/1.11.3 build 121\\/iOS 13.3\"],\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/dk\\/app\\/podimo-a-world-of-podcasts\\/id1476538730\"\n}, {\n\t\"user_agents\": [\"^Podimo\\/.*Android\"],\n\t\"app\": \"Podimo\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Podimo\\/1.11.3 build 91\\/Android 28\"],\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.podimo&hl=en_US\"\n}, {\n\t\"user_agents\": [\"BingPreview\\/\", \"adidxbot\\/\", \"bingbot\\/\"],\n\t\"app\": \"Microsoft Bingbot\",\n\t\"bot\": true,\n\t\"info_url\": \"https:\\/\\/www.bing.com\\/webmaster\\/help\\/which-crawlers-does-bing-use-8c184ec0\",\n\t\"examples\": [\"Mozilla\\/5.0 (Windows NT 6.1; WOW64) AppleWebKit\\/534 (KHTML, like Gecko) BingPreview\\/1.0b\"]\n}, {\n\t\"user_agents\": [\"^msnbot\\/\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Deezer Podcasters\\/1\\\\.0\"],\n\t\"bot\": true,\n\t\"app\": \"Deezer Podcasters\"\n}, {\n\t\"user_agents\": [\"^devcasts\\/.*CFNetwork\"],\n\t\"app\": \"DevCasts\",\n\t\"os\": \"ios\",\n\t\"description\": \"Our Devcasts app is a new kind of podcast listening app. It is simply the best way for developers to enjoy all of the excellent podcast content created for developers.\",\n\t\"examples\": [\"devcasts\\/1.0.1.00 CFNetwork\\/1197 Darwin\\/20.0.0\"],\n\t\"info_url\": \"http:\\/\\/devcasts.co\\/\"\n}, {\n\t\"user_agents\": [\"^got\\/\"],\n\t\"bot\": true,\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.podimo&hl=en_US\",\n\t\"developer_notes\": \"Got is a HTTP library for NodeJs\"\n}, {\n\t\"user_agents\": [\"INA dlweb\"],\n\t\"bot\": true,\n\t\"app\": \"l'Institut national de l'audiovisuel\",\n\t\"info_url\": \"https:\\/\\/institut.ina.fr\\/collecte-du-depot-legal-web\",\n\t\"developer_notes\": \"Institut National de l'Audiovisuel is a repository of all French radio and television audiovisual archives.\"\n}, {\n\t\"user_agents\": [\"^Instagram\\/\"],\n\t\"app\": \"Instagram\",\n\t\"examples\": [\"Instagram\\/252729634 CFNetwork\\/1126 Darwin\\/19.5.0\"]\n}, {\n\t\"user_agents\": [\"^SoundOn\\/[\\\\d.]+s+\\\\(Linux;Android\", \"^SoundOn\\/[^12]\\\\.d+\\\\.d+$\", \"^SoundOn\\/1\\\\.[^1][^0-2]?\\\\.d+$\"],\n\t\"app\": \"SoundOn\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"SoundOn\\/1.9.17 (Linux;Android 10) ExoPlayerLib\\/2.9.4\"],\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^SoundOn\\/1\\\\.1[0-2]\\\\.\\\\d+$\", \"^SoundOn\\/2\\\\.\\\\d+\\\\.\\\\d+$\", \"^SoundOn\\/[\\\\d.]+s+\\\\(iOS\"],\n\t\"app\": \"SoundOn\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"SoundOn\\/1.10.1\", \"SoundOn\\/2.2.0\", \"SoundOn\\/2.2.2 (iOS)\"],\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"^SoundOn\\/[\\\\d.]+\\\\s+\\\\(bot\"],\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Podverse\\/.*Android Mobile App\"],\n\t\"app\": \"Podverse\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/play.google.com\\/store\\/apps\\/details?id=com.podverse&hl=en_US\",\n\t\"description\": \"Open source podcast catcher for Android, with clip-sharing, playlists, device syncing and more.\",\n\t\"examples\": [\"Podverse/Android Mobile App\",\"Podverse/F-Droid Android Mobile App/\",\"Podverse/Android Mobile App/Mozilla/5.0 (Linux; Android 12; SM-S134DL Build/SP1A.210812.016; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/105.0.5195.136 Mobile Safari/537.36\"],\n\t\"developer_notes\": \"The standard device user agent was formerly concatenated to the end of the Podverse\\/Android Mobile App\\/ user agent.\"\n}, {\n\t\"user_agents\": [\"^Podverse\\/iOS Mobile App\"],\n\t\"app\": \"Podverse\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Podverse/iOS Mobile App\",\"Podverse\\/iOS Mobile App\\/Mozilla\\/5.0 (iPhone; CPU iPhone OS 13_6_1 like Mac OS X) AppleWebKit\\/605.1.15 (KHTML, like Gecko) Mobile\\/1234\"],\n\t\"description\": \"Open source podcast catcher for iOS, with clip-sharing, playlists, device syncing and more.\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/us\\/app\\/podverse\\/id1390888454\",\n\t\"developer_notes\": \"The standard device user agent was formerly concatenated to the end of the Podverse\\/iOS Mobile App\\/ user agent.\"\n}, {\n\t\"user_agents\": [\"^Podverse\\/Feed Parser\"],\n\t\"bot\": true,\n\t\"app\": \"Podverse Feed Parser\",\n\t\"info_url\": \"https:\\/\\/podverse.fm\",\n\t\"description\": \"The Podverse feed parser.\",\n\t\"developer_notes\": \"This service parses publicly-accessible RSS feeds on a timer, then stores parsed data in the Podverse database.\"\n}, {\n\t\"user_agents\": [\"^Podcast\\/1.\"],\n\t\"app\": \"\\u5c0f\\u5b87\\u5b99\",\n\t\"info_url\": \"https:\\/\\/www.coolapk.com\\/apk\\/app.podcast.cosmos\",\n\t\"description\": \"Cosmos, a chinese podcast app\"\n}, {\n\t\"user_agents\": [\"^Xiaoyuzhou\\/.*Android\\/\"],\n\t\"app\": \"Xiao Yu Zhou\",\n\t\"description\": \"Xiao Yu Zhou, a podcast app\",\n\t\"os\": \"android\",\n\t\"examples\": [\"Xiaoyuzhou\\/1.9.6 Android\\/10\"]\n}, {\n\t\"user_agents\": [\"^Xiaoyuzhou\\/(?!.*(Android\\/))\"],\n\t\"app\": \"Xiao Yu Zhou\",\n\t\"description\": \"Xiao Yu Zhou, a podcast app\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/cn\\/app\\/%E5%B0%8F%E5%AE%87%E5%AE%99-%E4%B8%80%E8%B5%B7%E5%90%AC%E6%92%AD%E5%AE%A2\\/id1488894313\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"Xiaoyuzhou\\/1.9.0\", \"Xiaoyuzhou\\/1.5.1\"]\n}, {\n\t\"user_agents\": [\"^yacybot\"],\n\t\"app\": \"YaCy\",\n\t\"bot\": true,\n\t\"description\": \"Decentralized Web Search\",\n\t\"info_url\": \"http:\\/\\/yacy.net\\/bot.html\",\n\t\"examples\": [\"yacybot (\\/global; amd64 Linux 5.9.8-zen1-1-zen; java 1.8.0_265; Europe\\/de) http:\\/\\/yacy.net\\/bot.html\"]\n}, {\n\t\"user_agents\": [\"^Podcast-CriticalMention\\/\"],\n\t\"app\": \"Critical Mention\",\n\t\"description\": \"Critical Mention is a business intelligence company, monitoring podcasts on the web for their clients\",\n\t\"bot\": true,\n\t\"examples\": [\"Podcast-CriticalMention\\/1.0\"]\n}, {\n\t\"user_agents\": [\"^RSSOwl.*Windows\"],\n\t\"app\": \"RSSOwl\",\n\t\"description\": \"A Mac and Windows app, to help organize, search, and read feeds\",\n\t\"device\": \"pc\",\n\t\"os\": \"windows\",\n\t\"info_url\": \"http:\\/\\/www.rssowl.org\\/\",\n\t\"examples\": [\"RSSOwl\\/2.2.1.201312301314 (Windows; U; en)\"]\n}, {\n\t\"user_agents\": [\"^ltx71 \"],\n\t\"app\": \"LTX71\",\n\t\"info_url\": \"http:\\/\\/ltx71.com\\/\",\n\t\"description\": \"We continuously scan the internet for security research purposes.\",\n\t\"bot\": true,\n\t\"examples\": [\"ltx71 - (http:\\/\\/ltx71.com\\/)\"]\n}, {\n\t\"user_agents\": [\"^bl.uk_ldfc_bot\"],\n\t\"app\": \"British Library\",\n\t\"info_url\": \"http:\\/\\/www.bl.uk\\/aboutus\\/legaldeposit\\/websites\\/websites\\/faqswebmaster\\/index.html\",\n\t\"description\": \"British Library's legal deposit web crawler\",\n\t\"bot\": true,\n\t\"examples\": [\"bl.uk_ldfc_bot\\/3.4.0-20200518 ( http:\\/\\/www.bl.uk\\/aboutus\\/legaldeposit\\/websites\\/websites\\/faqswebmaster\\/index.html)\"]\n}, {\n\t\"user_agents\": [\"Archive-It;\"],\n\t\"app\": \"Internet Archive\",\n\t\"info_url\": \"https:\\/\\/archive-it.org\\/files\\/site-owners-special.html\",\n\t\"description\": \"Archive-It is a web archiving service that allows institutions to build and preserve collections of born digital content saving this content for future generations.\",\n\t\"bot\": true,\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; special_archiver; Archive-It; http:\\/\\/archive-it.org\\/files\\/site-owners-special.html)\"]\n}, {\n\t\"user_agents\": [\"VurblBot\"],\n\t\"app\": \"Vurbl\",\n\t\"info_url\": \"https:\\/\\/vurbl.com\\/about-us\\/\",\n\t\"description\": \"An audio streaming destination\",\n\t\"bot\": true,\n\t\"examples\": [\"Mozilla\\/5.0 https:\\/\\/vurbl.com VurblBot\\/1.0\"]\n}, {\n\t\"user_agents\": [\"PetalBot\"],\n\t\"app\": \"PetalBot\",\n\t\"info_url\": \"https:\\/\\/aspiegel.com\\/petalbot\",\n\t\"description\": \"PetalBot is an automatic program of the Petal search engine.\",\n\t\"bot\": true,\n\t\"examples\": [\"Mozilla\\/5.0 (Linux; Android 7.0;) AppleWebKit\\/537.36 (KHTML, like Gecko) Mobile Safari\\/537.36 (compatible; PetalBot; https:\\/\\/aspiegel.com\\/petalbot)\"]\n}, {\n\t\"user_agents\": [\"PodhoundBeta\"],\n\t\"app\": \"Podhound\",\n\t\"info_url\": \"http:\\/\\/podhound.co\",\n\t\"description\": \"AI-powered podcast discovery\",\n\t\"bot\": true,\n\t\"examples\": [\"PodhoundBeta\"],\n\t\"developer_notes\": \"'It grabs it once to get the audio file length.', says the developer.\"\n}, {\n\t\"user_agents\": [\"hermespod.com\\/\"],\n\t\"app\": \"HermesPod\",\n\t\"info_url\": \"http:\\/\\/hermespod.com\\/\",\n\t\"description\": \"HermesPod is the easiest way to subscribe, download and listen to podcasts. It's a Windows app.\",\n\t\"examples\": [\"hermespod.com\\/v1.5.x\"],\n\t\"developer_notes\": \"HermesPod is no longer supported by its author.\"\n}, {\n\t\"user_agents\": [\"^gvfs\\/\", \"^rhythmbox\\/\"],\n\t\"app\": \"Rhythmbox\",\n\t\"info_url\": \"https:\\/\\/gitlab.gnome.org\\/GNOME\\/rhythmbox\",\n\t\"description\": \"Rhythmbox is your one-stop multimedia application, supporting a music library, multiple playlists, internet radio, and more.\",\n\t\"examples\": [\"gvfs\\/1.46.1\"],\n\t\"developer_notes\": \"The new UA is Rhythmbox: https:\\/\\/gitlab.gnome.org\\/GNOME\\/rhythmbox\\/-\\/issues\\/1855\"\n}, {\n\t\"user_agents\": [\"archive.org_bot\"],\n\t\"app\": \"Archive.org\",\n\t\"info_url\": \"https:\\/\\/archive.org\\/details\\/archive.org_bot\",\n\t\"description\": \"The Internet Archive is a nonprofit digital library that preserves web data and makes it available for research purposes through the Wayback Machine.\",\n\t\"bot\": true,\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; archive.org_bot http:\\/\\/archive.org\\/details\\/archive.org_bot)\"]\n}, {\n\t\"user_agents\": [\"AAABot\"],\n\t\"app\": \"AAABot - unknown bot\",\n\t\"bot\": true,\n\t\"examples\": [\"AAABot\"]\n}, {\n\t\"user_agents\": [\"^MixerBox\\/.*Android\"],\n\t\"app\": \"MixerBox\",\n\t\"os\": \"android\",\n\t\"examples\": [\"MixerBox\\/12.33 (Linux;Android 11) ExoPlayerLib\\/2.11.1\"]\n}, {\n\t\"user_agents\": [\"^MixerBox\\/.*iOS\"],\n\t\"app\": \"MixerBox\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"MixerBox\\/807.iOS (iPhone; iOS 14.4; en_US)\"]\n}, {\n\t\"user_agents\": [\"^Podcastindex\\\\.org\\/\"],\n\t\"app\": \"Podcastindex.org\",\n\t\"bot\": true,\n\t\"svg\": \"podcast-index.svg\",\n\t\"info_url\": \"https:\\/\\/podcastindex.org\\/\",\n\t\"examples\": [\"Podcastindex.org\\/v0.3.3 (Aggrivate)\"]\n}, {\n\t\"user_agents\": [\"^sp-agent\"],\n\t\"app\": \"Samsung Podcasts\",\n\t\"info_url\": \"https:\\/\\/developer.samsung.com\\/podcasts\",\n\t\"examples\": [\"sp-agent\"]\n}, {\n\t\"user_agents\": [\"^MixerBox\\/\"],\n\t\"app\": \"Mixerbox\",\n\t\"info_url\": \"https:\\/\\/www.mixerbox.com\\/\",\n\t\"examples\": [\"MixerBox\\/869 CFNetwork\\/1300.1 Darwin\\/21.0.0\"],\n\t\"developer_notes\": \"This app appears to use a RSS useragent of AmazonCloudFront (or it may simply be blank)\"\n}, {\n\t\"user_agents\": [\"^Tumult\"],\n\t\"app\": \"Tumult\",\n\t\"description\": \"Et le podcast devient social\",\n\t\"device\": \"phone\",\n\t\"examples\": [\"Tumult\"],\n\t\"info_url\": \"https:\\/\\/tumult-podcast.com\\/\"\n}, {\n\t\"user_agents\": [\"^Vodacast\"],\n\t\"app\": \"Vodacast\",\n\t\"description\": \"Podcasts with deeper digital stories\",\n\t\"device\": \"phone\",\n\t\"info_url\": \"https:\\/\\/auddiainc.com\\/vodacast-app\\/\"\n}, {\n\t\"user_agents\": [\"^Palco MP3\"],\n\t\"app\": \"Palco MP3\",\n\t\"info_url\": \"https:\\/\\/www.palcomp3.com.br\\/\",\n\t\"examples\": [\"Palco MP3\\/3.13.18 (Linux;Android 11) ExoPlayerLib\\/2.11.0\"]\n}, {\n\t\"user_agents\": [\"^TheEconomist-Darwin-android\"],\n\t\"app\": \"Economist Espresso\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"examples\": [\"TheEconomist-Darwin-android-2.1.1-master-2999-2001024\"]\n}, {\n\t\"user_agents\": [\"^TheEconomist-Darwin-ios\"],\n\t\"app\": \"Economist Espresso\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"examples\": [\"TheEconomist-Darwin-ios-2.1.1-master-2999-2001024\"]\n}, {\n\t\"user_agents\": [\"^AudioWave iOS\"],\n\t\"app\": \"AudioWave\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/be\\/app\\/audiowave-podcast-player\\/id1602776751\",\n\t\"examples\": [\"AudioWave iOS\"]\n}, {\n\t\"user_agents\": [\"iOS VG Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS VG Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"VG iOS app\",\n\t\"description\": \"VG iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Aftonbladet Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Aftonbladet Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Aftonbladet iOS app\",\n\t\"description\": \"Aftonbladet iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Sportbladet Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Sportbladet Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Sportbladet iOS app\",\n\t\"description\": \"Sportbladet iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS AP Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS AP Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Aftenposten iOS app\",\n\t\"description\": \"Aftenposten iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS BT Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS BT Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Bergens Tidende iOS app\",\n\t\"description\": \"Bergens Tidende iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS SA Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS SA Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Stavanger Aftenblad iOS app\",\n\t\"description\": \"Stavanger Aftenblad iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS SvD Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS SvD Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Svenska Dagbladet iOS app\",\n\t\"description\": \"Svenska Dagbladet iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS E24 Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS E24 Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"E24 iOS app\",\n\t\"description\": \"E24 iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Askoyvaringen Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Askoyvaringen Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Askoyvaringen iOS app\",\n\t\"description\": \"Askoyvaringen iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Bygdanytt Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Bygdanytt Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Bygdanytt iOS app\",\n\t\"description\": \"Bygdanytt iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Strilen Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Strilen Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Strilen iOS app\",\n\t\"description\": \"Strilen iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"iOS Vestnytt Hermes\\/\"],\n\t\"examples\": [\"AppleCoreMedia\\/1.0.0.19E239 (iPhone; U; CPU OS 15_4 like Mac OS X; sv_se) iOS Vestnytt Hermes\\/90.0.0 _app_\"],\n\t\"app\": \"Vestnytt iOS app\",\n\t\"description\": \"Vestnytt iOS app\",\n\t\"os\": \"ios\"\n}, {\n\t\"user_agents\": [\"Android VG Hermes\\/\"],\n\t\"examples\": [\"Android VG Hermes/1000094692 app vg_app_ VG/Snarvei/1000094692 VG-App\"],\n\t\"app\": \"VG Android app\",\n\t\"description\": \"VG Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Android Aftonbladet Hermes\\/\"],\n\t\"examples\": [\"Android Aftonbladet Hermes/1000094692 _app_\"],\n\t\"app\": \"Aftonbladet Android app\",\n\t\"description\": \"Aftonbladet Android app\",\n\t\"os\": \"android\"\n},{\n\t\"user_agents\": [\"Android Sportbladet Hermes\\/\"],\n\t\"examples\": [\"Android Sportbladet Hermes/1000094692 _app_\"],\n\t\"app\": \"Sportbladet Android app\",\n\t\"description\": \"Sportbladet Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Android AP Hermes\\/\"],\n\t\"examples\": [\"Android AP Hermes/1000094692 _app_\"],\n\t\"app\": \"Aftenposten Android app\",\n\t\"description\": \"Aftenposten Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Android BT Hermes\\/\"],\n\t\"examples\": [\"Android BT Hermes/1000094692 _app_\"],\n\t\"app\": \"Bergens Tidende Android app\",\n\t\"description\": \"Bergens Tidende Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Android SA Hermes\\/\"],\n\t\"examples\": [\"Android SA Hermes/1000094692 _app_\"],\n\t\"app\": \"Stavanger Aftenblad Android app\",\n\t\"description\": \"Stavanger Aftenblad Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"SvD Hermes\\/\"],\n\t\"examples\": [\"SvD Hermes/999999999 _app_\"],\n\t\"app\": \"Svenska Dagbladet Android app\",\n\t\"description\": \"Svenska Dagbladet Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"Android E24 Hermes\\/\"],\n\t\"examples\": [\"Android E24 Hermes/1000094692 _app_\"],\n\t\"app\": \"E24 Android app\",\n\t\"description\": \"E24 Android app\",\n\t\"os\": \"android\"\n}, {\n\t\"user_agents\": [\"^Riddler \"],\n\t\"examples\": [\"Riddler (http:\\/\\/riddler.io\\/about)\"],\n\t\"app\": \"F-Secure Riddler\",\n\t\"description\": \"an online research project which investigates algorithms for mapping the topology of the Internet\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^RSStT\"],\n\t\"examples\": [\"RSStT\\/2.2.1 RSS Reader\"],\n\t\"app\": \"RSS to Telegram\",\n\t\"description\": \"an RSS to Telegram bot\",\n\t\"info_url\": \"https:\\/\\/apps.apple.com\\/be\\/app\\/audiowave-podcast-player\\/id1602776751\",\n\t\"bot\": true\n}, {\n\t\"user_agents\": [\"^Newsly\"],\n\t\"examples\": [\"Newsly\"],\n\t\"app\": \"Newsly\",\n\t\"device\": \"phone\",\n\t\"description\": \"Stop Scrolling, Start Listening.\",\n\t\"info_url\": \"https:\\/\\/www.newsly.me\\/\"\n}, {\n\t\"user_agents\": [\"KaiOS Downloader\"],\n\t\"examples\": [\"KaiOS Downloader\"],\n\t\"os\": \"kaios\",\n\t\"device\": \"phone\",\n\t\"developer_notes\": \"This is the KaiOS Downloader library, and this could refer to any app on this platform\"\n}, {\n\t\"user_agents\": [\"CPod\\/\"],\n\t\"examples\": [\"CPod\\/1.27.1 (github.com\\/z-------------)\"],\n\t\"info_url\": \"https:\\/\\/github.com\\/z-------------\\/CPod\",\n\t\"app\": \"CPod\",\n\t\"device\": \"pc\",\n\t\"description\": \"A simple, beautiful podcast app, for Windows, MacOS and Linux\"\n}, {\n\t\"user_agents\": [\"DataForSeoBot\\/\"],\n\t\"examples\": [\"Mozilla\\/5.0 (compatible; DataForSeoBot\\/1.0; +https:\\/\\/dataforseo.com\\/dataforseo-bot)\"],\n\t\"info_url\": \"https:\\/\\/dataforseo.com\\/dataforseo-bot\",\n\t\"app\": \"DataforSEO\",\n\t\"bot\": true,\n\t\"description\": \"Working on the biggest available backlink database on the web that every single member of the community, including you, can use and benefit from.\"\n}, {\n\t\"user_agents\": [\"Bullhorn\\/\"],\n\t\"examples\": [\"Bullhorn\\/1.0 (+http:\\/\\/bullhorn.fm\\/)\"],\n\t\"info_url\": \"http:\\/\\/bullhorn.fm\\/\",\n\t\"app\": \"Bullhorn\"\n}, {\n\t\"user_agents\": [\"gPodder\\/.*Linux\"],\n\t\"examples\": [\"gPodder\\/3.10.21 (+http:\\/\\/gpodder.org\\/) Linux\", \"gPodder\\/3.10.15 (+http:\\/\\/gpodder.org\\/) Linux\\/5.4.0-74-generic\", \"gPodder\\/3.10.15 (+http:\\/\\/gpodder.org\\/) Linux\\/5.4.0-90-generic\", \"gPodder\\/3.10.17 (+http:\\/\\/gpodder.org\\/) Linux\\/5.11.0-49-generic\"],\n\t\"info_url\": \"http:\\/\\/gpodder.org\\/\",\n\t\"app\": \"gPodder\",\n\t\"device\": \"pc\"\n}, {\n\t\"user_agents\": [\"AirableBot-Podcast\\/\"],\n\t\"examples\": [\"AirableBot-Podcast\\/1.0 (+https\\/\\/www.airablenow.com)\"],\n\t\"info_url\": \"https\\/\\/www.airablenow.com\",\n\t\"bot\": true,\n\t\"description\": \"An aggregator of internet radio and podcasts, for connected devices.\"\n}, {\n\t\"user_agents\": [\"Podcorn\\/\"],\n\t\"examples\": [\"Podcorn\\/1.0\"],\n\t\"info_url\": \"https:\\/\\/podcorn.com\\/\",\n\t\"bot\": true,\n\t\"description\": \"The leading podcast influencer marketplace. Connecting unique voices to unique brands for native advertising.\"\n}, {\n\t\"user_agents\": [\"RedCircle\"],\n\t\"examples\": [\"RedCircle\"],\n\t\"info_url\": \"https:\\/\\/redcircle.com\\/\",\n\t\"bot\": true,\n    \"app\": \"RedCircle\",\n\t\"description\": \"A platform for podcasts and brands to scale their message.\"\n}, {\n\t\"user_agents\": [\"ProCast\"],\n\t\"examples\": [\"ProCast\\/1 CFNetwork\\/1240.0.4 Darwin\\/20.6.0\"],\n\t\"info_url\": \"https:\\/\\/podcast-app.de\\/\",\n\t\"description\": \"The new generation of Podcast player.\"\n}, {\n\t\"user_agents\": [\"AnchorImport\"],\n\t\"examples\": [\"AnchorImport\\/1.0\"],\n\t\"description\": \"Anchor's tool for importing podcasts\",\n\t\"bot\": true,\n\t\"developer_notes\": \"This useragent is used during a user importing a podcast to the Anchor platform\"\n}, {\n\t\"user_agents\": [\"^Podio\\/\"],\n\t\"examples\": [\"Podio\\/1.0\"],\n\t\"description\": \"Podcasts + Radio = Podio\",\n\t\"info_url\": \"https:\\/\\/podio.radio\\/\",\n\t\"bot\": true,\n\t\"developer_notes\": \"This useragent appears to download and cache audio\"\n}, {\n\t\"user_agents\": [\"^Playapod\"],\n\t\"app\": \"Playapod\",\n\t\"examples\": [\"Playapod/2.4.11\"],\n\t\"description\": \"Best Cross-Platform Podcast and News App\",\n\t\"info_url\": \"https:\\/\\/playapod.com\\/\"\n}, {\n\t\"user_agents\": [\"Zune\\/\"],\n    \"app\": \"Zune\",\n\t\"examples\": [\"Zune\\/4.8\"]\n}, {\n\t\"user_agents\": [\"Headliner\\/\"],\n    \"app\":\"Headliner\",\n    \"examples\": [\"Headliner/1.0.0 +https://headliner.app\"],\n    \"bot\":true,\n    \"developer_notes\": \"A tool that takes audio to reformat it for video platforms\"\n}, {\n\t\"user_agents\": [\"Iframely\\/\"],\n    \"app\":\"Iframely\",\n    \"examples\": [\"Iframely/1.3.1 (+https://iframely.com/docs/about)\"],\n    \"info_url\": \"https:\\/\\/iframely.com\\/docs\\/about\"\n}, {\n\t\"user_agents\": [\"^(radio\\\\.[a-zA-Z]{2,3}|GetPodcast) [0-9]+.[0-9]+.[0-9]+ \\\\([a-zA-Z]+;( iPhone OS)? .+; .+\\\\)$\"],\n\t\"app\": \"radio.net\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/www.radio.net\\/\",\n\t\"examples\": [\"radio.fr 5.6.22 (iPhone; iPhone OS 16.4; fr_FR)\", \"radio.de 5.6.22 (iPad; iPhone OS 16.4; de_DE)\", \"radio.de 5.6.22 (iPhone; iPhone OS 16.4; de_DE)\", \"radio.net 5.7.3 (iPhone; iPhone OS 17.0.1; fr_FR)\", \"GetPodcast 5.6.22 (iPhone; iPhone OS 16.4; de_DE)\", \"GetPodcast 5.6.22 (iPhone; 16.4; de_DE)\"]\n}, {\n\t\"user_agents\": [\"^(radio\\\\.[a-zA-Z]{2,3}|GetPodcast)( |\\\\/)[0-9]+(\\\\.[0-9]+){3} \\\\([a-zA-Z]+;( ?(Android))? [\\\\.\\\\d]+(; [a-z]{1,3}(\\\\_|\\\\-)[a-zA-Z]{2,4})?\\\\)( ExoPlayerLib\\\\/[\\\\.\\\\d].+)?$\"],\n\t\"app\": \"radio.net\",\n\t\"device\": \"phone\",\n\t\"os\": \"android\",\n\t\"info_url\": \"https:\\/\\/www.radio.net\\/\",\n\t\"examples\": [\"radio.de/5.10.5.3 (Linux; 13) ExoPlayerLib/2.18.2\", \"radio.de/5.10.3.0 (Linux;Android 13) ExoPlayerLib/2.18.2\", \"radio.de/5.10.5.3 (Linux; Android 13) ExoPlayerLib/2.18.2\", \"radio.de/5.10.5.3 (Linux; Android 13; de_DE) ExoPlayerLib/2.18.2\", \"radio.de/5.10.5.3 (Linux; Android 13; zn-Hans) ExoPlayerLib/2.18.2\", \"GetPodcast/5.10.5.3 (Linux; Android 13; de_DE) ExoPlayerLib/2.18.2\"]\n}, {\n\t\"user_agents\": [\"^SYOK.+(iOS|Darwin)\"],\n\t\"app\": \"SYOK\",\n\t\"device\": \"phone\",\n\t\"os\": \"ios\",\n\t\"info_url\": \"https:\\/\\/syok.my\\/\",\n\t\"examples\": [\"SYOK/965 CFNetwork/1406.0.4 Darwin/22.4.0\"]\n}]\n"
  },
  {
    "path": "data/podlove_v2_schema.json",
    "content": "{\"swagger\":\"2.0\",\"info\":{\"title\":\"soundbite API\",\"description\":\"Just another WordPress site\",\"version\":\"5.8.1\",\"contact\":{\"email\":\"dev-email@flywheel.local\"}},\"host\":\"localhost:10013\",\"basePath\":\"\\/wp-json\",\"tags\":[],\"schemes\":[\"http\"],\"paths\":{\"\\/podlove\\/v2\\/analytics\\/episodes\":{\"get\":{\"tags\":[\"analytics\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"format\",\"in\":\"query\",\"description\":\"\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/analytics\\/episodes\\/{id}\":{\"get\":{\"tags\":[\"analytics\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"format\",\"in\":\"query\",\"description\":\"\",\"required\":false,\"type\":\"string\"},{\"name\":\"id\",\"in\":\"path\",\"description\":\"\",\"required\":true,\"type\":\"integer\",\"format\":\"int64\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/analytics\\/episodes\\/{ids}\":{\"get\":{\"tags\":[\"analytics\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"format\",\"in\":\"query\",\"description\":\"\",\"required\":false,\"type\":\"string\"},{\"name\":\"ids\",\"in\":\"path\",\"description\":\"\",\"required\":true,\"type\":\"integer\",\"format\":\"int64\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/podcast\":{\"get\":{\"tags\":[\"podcast\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"podcast\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"podcast\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"podcast\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/episodes\":{\"get\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"filter\",\"in\":\"query\",\"description\":\"The filter parameter is used to filter the collection of episodes\",\"required\":false,\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"publish\",\"draft\"]},\"collectionFormat\":\"multi\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/episodes\\/{id}\":{\"get\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"},{\"name\":\"title\",\"in\":\"formData\",\"description\":\"Clear, concise name for your episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"subtitle\",\"in\":\"formData\",\"description\":\"Single sentence describing the episode..\",\"required\":false,\"type\":\"string\"},{\"name\":\"summary\",\"in\":\"formData\",\"description\":\"A summary of the episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"number\",\"in\":\"formData\",\"description\":\"An epsiode number.\",\"required\":false,\"type\":\"string\"},{\"name\":\"slug\",\"in\":\"formData\",\"description\":\"Episode media file slug.\",\"required\":false,\"type\":\"string\"},{\"name\":\"explicit\",\"in\":\"formData\",\"description\":\"explicit content?\",\"required\":false,\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"yes\",\"no\"]},\"collectionFormat\":\"multi\"},{\"name\":\"soundbite_start\",\"in\":\"formData\",\"description\":\"Start value of podcast:soundbite tag\",\"required\":false,\"type\":\"string\"},{\"name\":\"soundbite_duration\",\"in\":\"formData\",\"description\":\"Duration value of podcast::soundbite tag\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"},{\"name\":\"title\",\"in\":\"query\",\"description\":\"Clear, concise name for your episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"subtitle\",\"in\":\"query\",\"description\":\"Single sentence describing the episode..\",\"required\":false,\"type\":\"string\"},{\"name\":\"summary\",\"in\":\"query\",\"description\":\"A summary of the episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"number\",\"in\":\"query\",\"description\":\"An epsiode number.\",\"required\":false,\"type\":\"string\"},{\"name\":\"slug\",\"in\":\"query\",\"description\":\"Episode media file slug.\",\"required\":false,\"type\":\"string\"},{\"name\":\"explicit\",\"in\":\"query\",\"description\":\"explicit content?\",\"required\":false,\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"yes\",\"no\"]},\"collectionFormat\":\"multi\"},{\"name\":\"soundbite_start\",\"in\":\"query\",\"description\":\"Start value of podcast:soundbite tag\",\"required\":false,\"type\":\"string\"},{\"name\":\"soundbite_duration\",\"in\":\"query\",\"description\":\"Duration value of podcast::soundbite tag\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"},{\"name\":\"title\",\"in\":\"query\",\"description\":\"Clear, concise name for your episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"subtitle\",\"in\":\"query\",\"description\":\"Single sentence describing the episode..\",\"required\":false,\"type\":\"string\"},{\"name\":\"summary\",\"in\":\"query\",\"description\":\"A summary of the episode.\",\"required\":false,\"type\":\"string\"},{\"name\":\"number\",\"in\":\"query\",\"description\":\"An epsiode number.\",\"required\":false,\"type\":\"string\"},{\"name\":\"slug\",\"in\":\"query\",\"description\":\"Episode media file slug.\",\"required\":false,\"type\":\"string\"},{\"name\":\"explicit\",\"in\":\"query\",\"description\":\"explicit content?\",\"required\":false,\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"yes\",\"no\"]},\"collectionFormat\":\"multi\"},{\"name\":\"soundbite_start\",\"in\":\"query\",\"description\":\"Start value of podcast:soundbite tag\",\"required\":false,\"type\":\"string\"},{\"name\":\"soundbite_duration\",\"in\":\"query\",\"description\":\"Duration value of podcast::soundbite tag\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"delete\":{\"tags\":[\"episodes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/chapters\\/{id}\":{\"get\":{\"tags\":[\"chapters\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"chapters\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"chapters\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"chapters\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"delete\":{\"tags\":[\"chapters\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/transcripts\\/{id}\\/voices\":{\"post\":{\"tags\":[\"transcripts\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"post id\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"transcripts\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"post id\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"transcripts\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"post id\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/transcripts\\/{id}\":{\"get\":{\"tags\":[\"transcripts\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"episode id\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/social\\/services\":{\"get\":{\"tags\":[\"social\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"category\",\"in\":\"query\",\"description\":\"category: social, donation, internal\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/social\\/services\\/contributor\\/{id}\":{\"get\":{\"tags\":[\"social\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"contributor id\",\"required\":true,\"type\":\"string\"},{\"name\":\"category\",\"in\":\"query\",\"description\":\"category: social, donation, internal\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/shownotes\":{\"get\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"episode_id\",\"in\":\"query\",\"description\":\"Limit result set by episode.\",\"required\":false,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/shownotes\\/{id}\":{\"get\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"delete\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"post\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/shownotes\\/{id}\\/unfurl\":{\"post\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"put\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}},\"patch\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for the object.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/shownotes\\/osf\":{\"post\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/shownotes\\/html\":{\"post\":{\"tags\":[\"shownotes\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/contributors\":{\"get\":{\"tags\":[\"contributors\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/contributors\\/groups\":{\"get\":{\"tags\":[\"contributors\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/contributors\\/roles\":{\"get\":{\"tags\":[\"contributors\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/contributors\\/{id}\":{\"get\":{\"tags\":[\"contributors\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for contributor.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}},\"\\/podlove\\/v2\\/contributors\\/episode\\/{id}\":{\"get\":{\"tags\":[\"contributors\"],\"summary\":\"\",\"description\":\"\",\"consumes\":[\"application\\/x-www-form-urlencoded\",\"multipart\\/form-data\"],\"produces\":[\"application\\/json\"],\"parameters\":[{\"name\":\"id\",\"in\":\"path\",\"description\":\"Unique identifier for episode.\",\"required\":true,\"type\":\"string\"}],\"security\":[{\"basic\":[]}],\"responses\":{\"200\":{\"description\":\"OK\"},\"404\":{\"description\":\"Not Found\"},\"400\":{\"description\":\"Bad Request\"}}}}},\"securityDefinitions\":{\"basic\":{\"type\":\"basic\"}}}"
  },
  {
    "path": "devbox.d/php/php-fpm.conf",
    "content": "[global]\npid = ${PHPFPM_PID_FILE}\nerror_log = ${PHPFPM_ERROR_LOG_FILE}\ndaemonize = yes\n\n[www]\n; user = www-data\n; group = www-data\nlisten = 127.0.0.1:${PHPFPM_PORT}\n; listen.owner = www-data\n; listen.group = www-data\npm = dynamic\npm.max_children = 5\npm.start_servers = 2\npm.min_spare_servers = 1\npm.max_spare_servers = 3\nchdir = /\n"
  },
  {
    "path": "devbox.d/php/php.ini",
    "content": "[php]\n\n; Put your php.ini directives here. For the latest default php.ini file, see https://github.com/php/php-src/blob/master/php.ini-production\n\n; memory_limit = 128M\n; expose_php = Off\n"
  },
  {
    "path": "devbox.json",
    "content": "{\n  \"$schema\": \"https://raw.githubusercontent.com/jetpack-io/devbox/0.10.2/.schema/devbox.schema.json\",\n  \"packages\": [\"php@latest\", \"php83Packages.composer@latest\", \"nodejs@20\"],\n  \"shell\": {\n    \"init_hook\": [\"echo 'Welcome to devbox!' > /dev/null\"],\n    \"scripts\": {\n      \"bootstrap\": \"make install\",\n      \"build\": \"make build --always-make\",\n      \"client\": [\"cd client\", \"npm run dev\"]\n    }\n  }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  db:\n    image: mysql:latest\n    container_name: db\n    restart: unless-stopped\n    environment:\n      MYSQL_DATABASE: wordpress\n      MYSQL_ROOT_PASSWORD: wordpress\n      MYSQL_USER: wordpress\n      MYSQL_PASSWORD: wordpress\n    expose:\n      - 3306\n    networks:\n      - app-network\n\n  wordpress:\n    build: .\n\n    ports:\n      - 8080:80\n    restart: always\n    depends_on:\n      - db\n    environment:\n      WORDPRESS_DB_HOST: db:3306\n      WORDPRESS_DB_USER: wordpress\n      WORDPRESS_DB_PASSWORD: wordpress\n      WORDPRESS_DB_NAME: wordpress\n    networks:\n      - app-network\n\nnetworks:\n  app-network:\n    driver: bridge\n"
  },
  {
    "path": "includes/about.php",
    "content": "<?php\n\nadd_action('admin_init', 'podlove_about_page_init');\nadd_action('admin_init', 'podlove_maybe_redirect_to_about_page', 20); // run after migrations\n\n/**\n * Redirects to about page once.\n *\n * To reset before a major/minor release,\n * add `delete_site_option(\"podlove_seen_about\")` as a migration.\n */\nfunction podlove_maybe_redirect_to_about_page()\n{\n    if (!podlove_should_see_about_page()) {\n        return;\n    }\n\n    // show only once per upgrade and network\n    update_site_option('podlove_seen_about', true);\n\n    wp_safe_redirect(admin_url('admin.php?page=podlove_settings_handle&about'));\n}\n\nfunction podlove_should_see_about_page()\n{\n    global $pagenow;\n\n    if (!current_user_can('manage_options')) {\n        return false;\n    }\n\n    if (in_array($pagenow, ['update.php', 'update-core.php', 'plugins.php', 'plugin-install.php'])) {\n        return false;\n    }\n\n    if (get_site_option('podlove_seen_about')) {\n        return false;\n    }\n\n    return true;\n}\n\nfunction podlove_about_page_init()\n{\n    if (filter_input(INPUT_GET, 'page') !== 'podlove_settings_handle') {\n        return;\n    }\n\n    if (!isset($_GET['about'])) {\n        return;\n    }\n\n    // hide all admin notices\n    add_action('admin_notices', function () {\n        remove_all_actions('admin_notices');\n    }, -1);\n\n    add_filter('podlove_dashboard_page', 'podlove_about_page');\n\n    wp_register_style('podlove-about', \\Podlove\\PLUGIN_URL.'/css/about.css', [], \\Podlove\\get_plugin_header('Version'));\n    wp_enqueue_style('podlove-about');\n}\n\nfunction podlove_about_page($_)\n{\n    ?>\n\n<div class=\"wrap podlove-about-wrap\">\n\n\t<h1>\n\t\t<?php printf(__('Welcome to Podlove Publisher&nbsp;%s', 'podlove-podcasting-plugin-for-wordpress'), \\Podlove\\get_plugin_header('Version')); ?>\n\t</h1>\n\n\t<div class=\"about-text\">\n\t\t<?php printf(__('Thank you for updating! This version focuses on podcasting in WordPress Multisite environments.', 'podlove-podcasting-plugin-for-wordpress')); ?>\n\t</div>\n\n\t<div class=\"podlove-badge\"></div>\n\n\t<h2 class=\"nav-tab-wrapper\">\n\t\t<a href=\"#\" class=\"nav-tab nav-tab-active\">\n\t\t\t<?php _e('What&#8217;s New', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</a>\n\t</h2>\n\n\t<div class=\"changelog headline-feature\">\n\t\t<h2><?php _e('Networks: WordPress Multisite Support is Here', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t\t<!-- <div class=\"featured-image\">\n\t\t\t<img src=\"//s.w.org/images/core/4.1/theme.png?0\" />\n\t\t</div> -->\n\n\t\t<div class=\"feature-section top-feature\">\n\n\t\t\t<img src=\"<?php echo \\Podlove\\PLUGIN_URL.'/images/about/network.png'; ?>\" style=\"width: 50%; margin-left: 25%; margin-top: 1em\" />\n\n\t\t\t<h3>\n\t\t\t\tPodlove Publisher joins the networks section.<br>\n\t\t\t\tGet an overview of your network and quickly switch between podcasts.\n\t\t\t</h3>\n\t\t\t<p>\n\t\t\t\t<ul class=\"ul-disc\">\n\t\t\t\t\t<li>The network dashboard provides a birds-eye view over your podcast empire.</li>\n\t\t\t\t\t<li>Manage templates in your network and access them in all podcasts.</li>\n\t\t\t\t\t<li>Create podcast lists and use them in templates spanning multiple podcasts, for example to list the 10 latest episodes in your network.</li>\n\t\t\t\t</ul>\n\t\t\t</p>\n\n\t\t</div>\n\n\t\t<div class=\"clear\"></div>\n\n\t\t<hr />\n\n\t<div class=\"feature-section col two-col\">\n\t\t<h2>Upgrade Notices</h2>\n\n\t\t<div>\n\t\t\t<h4>Custom Template Parameters are Handled Differently.</h4>\n\t\t\t<p>\n\t\t\t\tThis section is relevant if you are using templates with custom variables passed in shortcodes, like this:\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<code>[podlove-template template=\"example\" param=\"foo\" dog=\"wow\"]</code>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\tBefore 2.1 you have accessed those variables simply by calling <code>param</code> and <code>dog</code>.\n\t\t\t\tFor compatibility, all shortcode options are now prefixed with <code>option.</code>,\n\t\t\t\tso you need to change those calls to <code>option.param</code> and <code>option.dog</code> etc.\n\t\t\t</p>\n\t\t</div>\n\n\t\t<div class=\"last-feature\">\n\t\t\t<h4>Other</h4>\n\t\t\t<p>\n\t\t\t\tThe Flattr parameter in <code>[podlove-episode-contributor-list]</code> now defaults to \"no\". If you like to include Flattr, use <code>[podlove-episode-contributor-list flattr=\"yes\"]</code>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<code>[podlove-web-player]</code> was renamed to <code>[podlove-episode-web-player]</code> to avoid clashes with the standalone web player plugin. For now, the old shortcode still works.\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<code>[podlove-subscribe-button]</code> was renamed to <code>[podlove-podcast-subscribe-button]</code> to avoid clashes with the standalone button plugin. For now, the old shortcode still works.\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\tIt is now preferred to reference templates using the <code>template</code> parameter instead of <code>id</code>: <code>[podlove-template template=\"example\"]</code>.\n\t\t\t</p>\n\t\t</div>\n\t</div>\n\n\t<hr />\n\n\t<div class=\"return-to-dashboard\">\n\n\t\t<a href=\"<?php echo esc_url(admin_url('admin.php?page=podlove_settings_handle')); ?>\"><?php\n            _e('Go to Podlove Dashboard &rarr;'); ?></a>\n\t</div>\n\n</div>\n\n<style type=\"text/css\">\n#screen-meta-links { display: none; }\n</style>\n\n\t<?php\n    return true;\n}\n"
  },
  {
    "path": "includes/api/admin/onboarding.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Admin;\n\nuse Podlove\\Modules\\Onboarding\\Onboarding;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_PodloveOnboarding_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_PodloveOnboarding_Controller extends \\WP_REST_Controller\n{\n    /**\n     * Constructor.\n     */\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'admin';\n    }\n\n    /**\n     * Register the component routes.\n     */\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/onboarding', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_onboarding_options'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_onboarding_options'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n                [\n                    'args' => [\n                        'banner_hide' => [\n                            'description' => __('Hide the banner', 'podlove-podcasting-plugin-for-wordpress'),\n                            'type' => 'boolean'\n                        ],\n                        'type' => [\n                            'description' => __('Type of the onboarding', 'podlove-podcasting-plugin-for-wordpress'),\n                            'type' => 'string',\n                            'enum' => ['start', 'import', 'reset']\n                        ],\n                    ]\n                ]\n            ],\n        ]);\n    }\n\n    /**\n     * GET route.\n     *\n     * @param mixed $request\n     */\n    public function get_item_permissions_check($request)\n    {\n        if (!current_user_can('administrator')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_onboarding_options($request)\n    {\n        $banner_flag = Onboarding::is_banner_hide();\n        $type = Onboarding::get_onboarding_type();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'banner_hide' => $banner_flag,\n            'type' => $type\n        ]);\n    }\n\n    /**\n     * PUT/PATCH/POST route.\n     *\n     * @param mixed $request\n     */\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('administrator')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_onboarding_options($request)\n    {\n        if (isset($request['banner_hide'])) {\n            $option = $request['banner_hide'];\n            Onboarding::set_banner_hide($option);\n        }\n\n        if (isset($request['type'])) {\n            $option = $request['type'];\n            Onboarding::set_onboarding_type($option);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n        ]);\n    }\n}\n"
  },
  {
    "path": "includes/api/admin/plus.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Admin;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Plus\\FileStorage;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_PodlovePlus_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_PodlovePlus_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'admin/plus';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/episodes_for_migration', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_episodes_for_migration'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/features', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_features'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/set_feature', [\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'set_feature'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/token', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_token'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/validate_token', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'validate_token'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/save_token', [\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'save_token'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n    }\n\n    public function get_episodes_for_migration($request)\n    {\n        $episodes = Episode::find_all_by_time();\n\n        if (empty($episodes)) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'episodes' => [],\n            ]);\n        }\n\n        $episodes_with_files = [];\n\n        foreach ($episodes as $episode) {\n            $media_files = $episode->media_files();\n            $episode_title = $episode->title();\n\n            $files = array_map(function ($file) {\n                $local_url = FileStorage::get_local_file_url($file);\n                $plus_url = $file->get_file_url();\n\n                return [\n                    'local_url' => $local_url,\n                    'plus_url' => $plus_url,\n                    'filename' => $file->get_file_name(),\n                ];\n            }, $media_files);\n\n            $episodes_with_files[] = [\n                'episode_title' => $episode_title,\n                'files' => $files,\n            ];\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'episodes' => $episodes_with_files,\n        ]);\n    }\n\n    public function get_features($request)\n    {\n        $podcast = Podcast::get();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'file_storage' => $podcast->plus_enable_storage,\n            'feed_proxy' => $podcast->plus_enable_proxy,\n        ]);\n    }\n\n    public function set_feature($request)\n    {\n        $feature = $request->get_param('feature');\n        $value = (bool) $request->get_param('value');\n        $valid_features = ['fileStorage', 'feedProxy'];\n\n        if (!in_array($feature, $valid_features)) {\n            return new \\Podlove\\Api\\Error\\ArgumentError(message: 'Invalid feature');\n        }\n\n        $podcast = Podcast::get();\n\n        if ($feature === 'fileStorage') {\n            $podcast->plus_enable_storage = $value;\n        }\n\n        if ($feature === 'feedProxy') {\n            $podcast->plus_enable_proxy = $value;\n        }\n\n        $podcast->save();\n\n        if ($feature === 'fileStorage') {\n            do_action('podlove_plus_enable_storage_changed', $value);\n        }\n\n        if ($feature === 'feedProxy') {\n            do_action('podlove_plus_enable_proxy_changed', $value);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse();\n    }\n\n    public function get_token($request)\n    {\n        $plus_module = \\Podlove\\Modules\\Plus\\Plus::instance();\n        $token = $plus_module->get_module_option('plus_api_token');\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'token' => $token ?: ''\n        ]);\n    }\n\n    public function validate_token($request)\n    {\n        $plus_module = \\Podlove\\Modules\\Plus\\Plus::instance();\n        $token = $plus_module->get_module_option('plus_api_token');\n\n        if (!$token) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'user' => null\n            ]);\n        }\n\n        $api = new \\Podlove\\Modules\\Plus\\API($plus_module, $token);\n        $user = $api->get_me();\n\n        if ($user && isset($user->email)) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'user' => [\n                    'email' => $user->email\n                ]\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'user' => null\n        ]);\n    }\n\n    public function save_token($request)\n    {\n        $token = $request->get_param('token');\n        $token = sanitize_text_field($token);\n\n        $plus_module = \\Podlove\\Modules\\Plus\\Plus::instance();\n        $plus_module->update_module_option('plus_api_token', $token);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'success' => true\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        if (!current_user_can('administrator')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "includes/api/analytics.php",
    "content": "<?php\n\nuse League\\Csv\\Writer;\n\nadd_action('rest_api_init', 'podlove_analytics_api_init');\n\nfunction podlove_analytics_api_init()\n{\n    $args = [\n        'format' => [\n            'sanitize_callback' => function ($param, $request, $key) {\n                return $param == 'csv' ? 'csv' : 'json';\n            },\n            'default' => 'json',\n        ],\n    ];\n\n    register_rest_route('podlove/v1', 'analytics/episodes', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episodes',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n    register_rest_route('podlove/v1', 'analytics/episodes/(?P<id>[\\d]+)', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episode',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n    register_rest_route('podlove/v1', 'analytics/episodes/(?P<ids>[\\d]+,[\\d,]+)', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episodes_selected',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n\n    register_rest_route('podlove/v2', 'analytics/episodes', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episodes',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n    register_rest_route('podlove/v2', 'analytics/episodes/(?P<id>[\\d]+)', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episode',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n    register_rest_route('podlove/v2', 'analytics/episodes/(?P<ids>[\\d]+,[\\d,]+)', [\n        'methods' => 'GET',\n        'callback' => 'podlove_api_analytics_episodes_selected',\n        'permission_callback' => 'podlove_api_analytics_permission_callback',\n        'args' => $args,\n    ]);\n}\n\nfunction podlove_api_analytics_permission_callback($request)\n{\n    if (!current_user_can('podlove_read_analytics')) {\n        return new WP_Error(\n            'rest_forbidden',\n            esc_html__('You cannot view the analytics resource.'),\n            ['status' => \\Podlove\\Api\\Permissons::authorization_status_code()]\n        );\n    }\n\n    return true;\n}\n\nfunction podlove_api_csv_response($data)\n{\n    header('Content-Type: text/csv');\n    header('Content-Disposition: attachment; filename=podlove-episode-downloads.csv');\n\n    $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n    // $csv->setEncodingFrom('utf-8');\n\n    $headers = array_keys($data[0]);\n    $csv->insertOne($headers);\n\n    $csv->insertAll($data);\n\n    echo $csv;\n    exit;\n}\n\nfunction podlove_api_analytics_episodes(WP_REST_Request $request)\n{\n    $data = \\Podlove\\Downloads_List_Data::get_data();\n\n    if ($request->get_param('format') == 'csv') {\n        podlove_api_csv_response($data);\n    } else {\n        $data = array_map('podlove_api_analytics_prepare_episode', $data);\n\n        return new WP_REST_Response($data);\n    }\n}\n\nfunction podlove_api_analytics_episodes_selected(WP_REST_Request $request)\n{\n    $ids = explode(',', $request['ids']);\n    $ids = array_map(function ($id) {\n        return (int) trim($id);\n    }, $ids);\n\n    $data = \\Podlove\\Downloads_List_Data::get_data();\n\n    $data = array_filter($data, function ($row) use ($ids) {\n        return in_array($row['post_id'], $ids);\n    });\n    $data = array_values($data);\n\n    if ($request->get_param('format') == 'csv') {\n        podlove_api_csv_response($data);\n    } else {\n        $data = array_map('podlove_api_analytics_prepare_episode', $data);\n\n        return new WP_REST_Response($data);\n    }\n}\n\nfunction podlove_api_analytics_episode(WP_REST_Request $request)\n{\n    $id = (int) $request['id'];\n    $post = get_post($id);\n\n    if (empty($post)) {\n        return new WP_REST_Response([], 404);\n    }\n\n    $data = \\Podlove\\Downloads_List_Data::get_data();\n    $data = array_map('podlove_api_analytics_prepare_episode', $data);\n\n    $data = array_filter($data, function ($row) use ($id) {\n        return $row['post_id'] == $id;\n    });\n    $data = array_values($data);\n\n    if ($request->get_param('format') == 'csv') {\n        podlove_api_csv_response($data);\n    } else {\n        $data = array_map('podlove_api_analytics_prepare_episode', $data);\n\n        return new WP_REST_Response($data[0]);\n    }\n}\n\nfunction podlove_api_analytics_prepare_episode($item)\n{\n    $item['_links'] = [\n        'self' => rest_url('podlove/v1/analytics/episodes/'.$item['post_id']),\n        'podlove:episode' => rest_url('wp/v2/episodes/'.$item['post_id']),\n    ];\n\n    return $item;\n}\n"
  },
  {
    "path": "includes/api/api.php",
    "content": "<?php\n\nnamespace Podlove;\n\nrequire_once \\Podlove\\PLUGIN_DIR.'lib/api/permissions.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'lib/api/response.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'lib/api/error.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'lib/api/validation.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/analytics.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/show.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/podcast.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/episodes.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/episodes/contributions.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/episodes/related_episodes.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/chapters.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/feeds.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/tools.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/admin/onboarding.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/admin/plus.php';\n"
  },
  {
    "path": "includes/api/chapters.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Chapters;\n\nuse Podlove\\Chapters\\Chapter;\nuse Podlove\\Chapters\\Chapters;\nuse Podlove\\Chapters\\Printer;\nuse Podlove\\Model\\Episode;\nuse Podlove\\NormalPlayTime;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_PodloveChapters_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_PodloveChapters_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'chapters';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                    'required' => 'true'\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'chapters' => [\n                        'description' => __('List of chapters, please use MP4Chaps format.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'array',\n                        'items' => [\n                            'type' => 'object',\n                            'properties' => [\n                                'start' => [\n                                    'description' => __('Chapter begin timestamp', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string',\n                                    'required' => 'true'\n                                ],\n                                'title' => [\n                                    'description' => __('Chapter title', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string',\n                                    'required' => 'true'\n                                ],\n                                'href' => [\n                                    'description' => __('Chapter url', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string'\n                                ],\n                                'image' => [\n                                    'description' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string'\n                                ]\n                            ]\n                        ],\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::chapters'\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'chapters' => [\n                        'description' => __('List of chapters, please use mp4chaps format.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'array',\n                        'items' => [\n                            'type' => 'object',\n                            'properties' => [\n                                'start' => [\n                                    'description' => __('Chapter begin timestamp', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string',\n                                    'required' => 'true'\n                                ],\n                                'title' => [\n                                    'description' => __('Chapter title', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string',\n                                    'required' => 'true'\n                                ],\n                                'href' => [\n                                    'description' => __('Chapter url', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string'\n                                ],\n                                'image' => [\n                                    'description' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string'\n                                ]\n                            ]\n                        ],\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::chapters'\n                    ]\n                ],\n                'description' => __('Edit the chapters list to an epsiode, old chapter list will be deleted.', 'podlove-podcasting-plugin-for-wordpress'),\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        return true;\n    }\n\n    public function get_item($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n\n        if ($episode) {\n            $data = array_map(function ($c) {\n                $c->title = html_entity_decode(trim($c->title));\n\n                return $c;\n            }, (array) json_decode($episode->get_chapters('json')));\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'chapters' => $data,\n            '_version' => 'v2',\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $chapters = new Chapters();\n        $npt = 0;\n\n        if (isset($request['chapters']) && is_array($request['chapters'])) {\n            for ($i = 0; $i < count($request['chapters']); ++$i) {\n                $timestamp = '';\n                if (isset($request['chapters'][$i]['start'])) {\n                    $timestamp = $request['chapters'][$i]['start'];\n                    $npt = NormalPlayTime\\Parser::parse($timestamp, 'ms');\n                }\n                $title = '';\n                if (isset($request['chapters'][$i]['title'])) {\n                    $title = $request['chapters'][$i]['title'];\n                }\n                $url = '';\n                if (isset($request['chapters'][$i]['href'])) {\n                    $url = $request['chapters'][$i]['href'];\n                }\n                $image = '';\n                if (isset($request['chapters'][$i]['image'])) {\n                    $image = $request['chapters'][$i]['image'];\n                }\n                if (strlen($url) == 0 && strlen($image) == 0) {\n                    $chapters->addChapter(new Chapter($npt, $title));\n                } else {\n                    if (strlen($image) == 0) {\n                        $chapters->addChapter(new Chapter($npt, $title, $url));\n                    } else {\n                        $chapters->addChapter(new Chapter($npt, $title, $url, $image));\n                    }\n                }\n            }\n        }\n\n        $chapters->setPrinter(new Printer\\Mp4chaps());\n        $episode_data['chapters'] = (string) $chapters;\n        $episode->update_attributes($episode_data);\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $chapters = new Chapters();\n        $npt = 0;\n\n        if (isset($request['chapters']) && is_array($request['chapters'])) {\n            for ($i = 0; $i < count($request['chapters']); ++$i) {\n                $timestamp = '';\n                if (isset($request['chapters'][$i]['start'])) {\n                    $timestamp = $request['chapters'][$i]['start'];\n                    $npt = NormalPlayTime\\Parser::parse($timestamp, 'ms');\n                }\n                $title = '';\n                if (isset($request['chapters'][$i]['title'])) {\n                    $title = $request['chapters'][$i]['title'];\n                }\n                $url = '';\n                if (isset($request['chapters'][$i]['href'])) {\n                    $url = $request['chapters'][$i]['href'];\n                }\n                $image = '';\n                if (isset($request['chapters'][$i]['image'])) {\n                    $image = $request['chapters'][$i]['image'];\n                }\n                if (strlen($url) == 0 && strlen($image) == 0) {\n                    $chapters->addChapter(new Chapter($npt, $title));\n                } else {\n                    if (strlen($image) == 0) {\n                        $chapters->addChapter(new Chapter($npt, $title, $url));\n                    } else {\n                        $chapters->addChapter(new Chapter($npt, $title, $url, $image));\n                    }\n                }\n            }\n        }\n\n        $chapters->setPrinter(new Printer\\JSON());\n        $episode_data['chapters'] = (string) $chapters;\n        $episode->update_attributes($episode_data);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode_data['chapters'] = '';\n        $episode->update_attributes($episode_data);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n}\n"
  },
  {
    "path": "includes/api/episodes/contributions.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Episodes;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\DefaultContribution;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\n\nclass WP_REST_PodloveEpisodeContributions_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'episodes';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/contributions', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'contributors' => [\n                        'description' => __('List of contributors of the episode', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'array',\n                        'items' => [\n                            'type' => 'object',\n                            'properties' => [\n                                'contributor_id' => [\n                                    'description' => __('Id of a contributor'),\n                                    'type' => 'integer',\n                                    'required' => 'true'\n                                ],\n                                'group_id' => [\n                                    'description' => __('Id of group of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'integer',\n                                    'validate_callback' => '\\Podlove\\Api\\Validation::isContributorGroupIdExist'\n                                ],\n                                'role_id' => [\n                                    'description' => __('Id of role of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'integer',\n                                    'validate_callback' => '\\Podlove\\Api\\Validation::isContributorRoleIdExist'\n                                ],\n                                'comment' => [\n                                    'description' => __('Comment to the contribution', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'string'\n                                ],\n                                'default_contributor' => [\n                                    'description' => __('Is the contributor a default contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                                    'type' => 'boolean'\n                                ]\n                            ]\n                        ]\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/contributions/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the contribution to an episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contribution'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_contribution'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_contribution'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n    }\n\n    public function get_item($request)\n    {\n        $id = $request->get_param('id');\n\n        $results = array_map(function ($contributor) {\n            if (self::isContributorVisible($contributor->contributor_id) == false) {\n                if (!current_user_can('edit_posts')) {\n                    return;\n                }\n            }\n\n            $comment = $contributor->comment;\n            if ($comment == null) {\n                $comment = '';\n            }\n            $group_id = $contributor->group_id;\n            if ($group_id == null) {\n                $group_id = 0;\n            }\n            $role_id = $contributor->role_id;\n            if ($role_id == null) {\n                $role_id = 0;\n            }\n\n            return [\n                'id' => $contributor->id,\n                'contributor_id' => $contributor->contributor_id,\n                'role_id' => $role_id,\n                'group_id' => $group_id,\n                'position' => $contributor->position,\n                'comment' => $comment,\n                'default_contributor' => self::isContributorDefault($contributor->contributor_id),\n            ];\n        }, EpisodeContribution::find_all_by_episode_id($id));\n\n        $results_clean = array_filter($results, fn ($item) => $this->isNotEmpty($item));\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'contribution' => $results_clean\n        ]);\n    }\n\n    public function get_contribution($request)\n    {\n        $id = $request->get_param('id');\n\n        $contribution = EpisodeContribution::find_by_id($id);\n        if (!$contribution) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (self::isContributorVisible($contribution->contributor_id) == false) {\n            if (!current_user_can('edit_posts')) {\n                return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n            }\n        }\n\n        $comment = $contribution->comment;\n        if ($comment == null) {\n            $comment = '';\n        }\n        $group_id = $contribution->group_id;\n        if ($group_id == null) {\n            $group_id = 0;\n        }\n        $role_id = $contribution->role_id;\n        if ($role_id == null) {\n            $role_id = 0;\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'id' => $contribution->id,\n            'contributor_id' => $contribution->contributor_id,\n            'role_id' => $role_id,\n            'group_id' => $group_id,\n            'position' => $contribution->position,\n            'comment' => $comment,\n            'default_contributor' => self::isContributorDefault($contribution->contributor_id),\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $contribution = new EpisodeContribution();\n        $contribution->episode_id = $id;\n        $contribution->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $contribution->id\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $contributions = EpisodeContribution::find_all_by_episode_id($id);\n        foreach ($contributions as $contribution) {\n            $contribution->delete();\n        }\n\n        $warning = [];\n        if (isset($request['contributors']) && is_array($request['contributors'])) {\n            for ($i = 0; $i < count($request['contributors']); ++$i) {\n                $contrib = new EpisodeContribution();\n                $contrib->episode_id = $id;\n                if (isset($request['contributors'][$i]['contributor_id'])) {\n                    $contrib->contributor_id = $request['contributors'][$i]['contributor_id'];\n                }\n                if (isset($request['contributors'][$i]['group_id'])) {\n                    $group_id = $request['contributors'][$i]['group_id'];\n                    $group = ContributorGroup::find_by_id($group_id);\n                    if ($group) {\n                        $contrib->group_id = $group_id;\n                    } else {\n                        if ($group_id > 0) {\n                            array_push($warning, 'group_id '.$group_id.' not exist!');\n                        }\n                    }\n                }\n                if (isset($request['contributors'][$i]['role_id'])) {\n                    $role_id = $request['contributors'][$i]['role_id'];\n                    $role = ContributorRole::find_by_id($role_id);\n                    if ($role) {\n                        $contrib->role_id = $role_id;\n                    } else {\n                        if ($role_id) {\n                            array_push($warning, 'role_id '.$role_id.' not exist!');\n                        }\n                    }\n                }\n                if (isset($request['contributors'][$i]['comment'])) {\n                    $contrib->comment = $request['contributors'][$i]['comment'];\n                }\n                if (isset($request['contributors'][$i]['position'])) {\n                    $contrib->position = $request['contributors'][$i]['position'];\n                }\n                $contrib->save();\n            }\n        }\n\n        if (empty($warning)) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'status' => 'ok'\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'warning' => $warning\n        ]);\n    }\n\n    public function update_contribution($request)\n    {\n        $id = $request->get_param('id');\n\n        $contribution = EpisodeContribution::find_by_id($id);\n        if (!$contribution) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $warning = [];\n        if (isset($request['contributor_id'])) {\n            $contribution->contributor_id = $request['contributor_id'];\n        }\n        if (isset($request['group_id'])) {\n            $group_id = $request['group_id'];\n            $group = ContributorGroup::find_by_id($group_id);\n            if ($group) {\n                $contribution->group_id = $group_id;\n            } else {\n                if ($group_id > 0) {\n                    array_push($warning, 'group_id '.$group_id.' not exist!');\n                }\n            }\n        }\n        if (isset($request['role_id'])) {\n            $role_id = $request['role_id'];\n            $role = ContributorRole::find_by_id($role_id);\n            if ($role) {\n                $contribution->role_id = $role_id;\n            } else {\n                if ($role_id > 0) {\n                    array_push($warning, 'role_id '.$role_id.' not exist!');\n                }\n            }\n        }\n        if (isset($request['comment'])) {\n            $contribution->comment = $request['comment'];\n        }\n        if (isset($request['position'])) {\n            $contribution->position = $request['position'];\n        }\n        $contribution->save();\n\n        if (empty($warning)) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'status' => 'ok'\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'warning' => $warning\n        ]);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $contributors = EpisodeContribution::find_all_by_episode_id($id);\n\n        foreach ($contributors as $contributor) {\n            $contributor->delete();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_contribution($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $contribution = EpisodeContribution::find_by_id($id);\n\n        if (!$contribution) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $contribution->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    private function isContributorDefault($id)\n    {\n        return (bool) DefaultContribution::find_all_by_property('contributor_id', $id);\n    }\n\n    private function isContributorVisible($id)\n    {\n        $contributor = Contributor::find_by_id($id);\n\n        return $contributor && $contributor->visibility > 0;\n    }\n\n    private function isNotEmpty($var)\n    {\n        return $var !== null && $var !== '';\n    }\n}\n"
  },
  {
    "path": "includes/api/episodes/related_episodes.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Episodes;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\RelatedEpisodes\\Model\\EpisodeRelation;\n\nclass WP_REST_PodloveEpisodeRelated_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'episodes';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/related', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'args' => [\n                    'status' => [\n                        'description' => __('Get also episodes with status draft.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['publish', 'draft', 'all']\n                    ],\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'get_items_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_items'],\n                'permission_callback' => [$this, 'update_items_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_items'],\n                'permission_callback' => [$this, 'delete_items_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/related', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode relation.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'args' => [\n                    'episode_id' => [\n                        'description' => __('Identifier for an episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true'\n                    ],\n                    'related_episode_id' => [\n                        'description' => __('Identifier for an related Episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true'\n                    ],\n                ],\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/related/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode relation.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'args' => [\n                    'status' => [\n                        'description' => __('Get also episodes with status draft.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['publish', 'draft']\n                    ],\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_items_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_items_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_items_permissions_check'],\n            ]\n        ]);\n    }\n\n    public function get_items($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $filter = $request->get_param('status');\n        if (!$filter || ($filter != 'draft' && $filter != 'all')) {\n            $filter = 'publish';\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode || ($filter == 'publish' && !$episode->is_published())) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($id);\n        }\n\n        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id.' OR right_episode_id = '.$episode->id);\n\n        $results = array_map(function ($relation) use ($filter, $episode) {\n            $related_id = $relation->left_episode_id;\n            $get_left_side = true;\n            if ($relation->right_episode_id != $episode->id) {\n                $related_id = $relation->right_episode_id;\n                $get_left_side = false;\n            }\n            $related_episode = Episode::find_by_id($related_id);\n            if ($related_episode) {\n                $related_episode_title = $related_episode->title();\n                $post = $related_episode->post();\n                if (($filter == 'publish' && $related_episode->is_published())\n                     || ($post && $filter == 'draft' && $post->post_status == 'draft')\n                    || $filter == 'all') {\n                    if ($get_left_side) {\n                        return [\n                            'episode_releation_id' => $relation->id,\n                            'related_episode_id' => $relation->left_episode_id,\n                            'related_episode_title' => $related_episode_title\n                        ];\n                    }\n                }\n\n                return [\n                    'episode_releation_id' => $relation->id,\n                    'related_episode_id' => $relation->right_episode_id,\n                    'related_episode_title' => $related_episode_title\n                ];\n            }\n        }, $relations);\n        // Delete the invalid entries\n        $results = array_filter($results);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'relatedEpisodes' => $results\n        ]);\n    }\n\n    public function get_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $isFilter = true;\n        $filter = $request->get_param('status');\n        if (!$filter || $filter != 'draft') {\n            $isFilter = false;\n        }\n\n        $relation = EpisodeRelation::find_by_id($id);\n\n        if (!$relation) {\n            $msg = 'sorry, we do not found the episode relation with ID '.$id;\n\n            return new \\Podlove\\Api\\Error\\NotFound('rest_not_found', $msg);\n        }\n\n        $right_episode = Episode::find_by_id($relation->right_episode_id);\n        $left_episode = Episode::find_by_id($relation->left_episode_id);\n\n        if (!$right_episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($relation->right_episode_id);\n        }\n        if (!$left_episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($relation->left_episode_id);\n        }\n\n        if ($isFilter || ($right_episode->is_published() && $left_episode->is_published())) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'episode_id' => $left_episode->id,\n                'related_episode_id' => $right_episode->id,\n                'related_episode_title' => $right_episode->title()\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function get_items_permissions_check($request)\n    {\n        $filter = $request->get_param('status');\n        if ($filter && ($filter == 'draft' || $filter == 'all') && (!current_user_can('edit_posts'))) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_items($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($id);\n        }\n\n        // Delete all old items\n        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id);\n        foreach ($relations as $relation) {\n            $relation->delete();\n        }\n\n        if (isset($request['related'])) {\n            if (is_array($request['related'])) {\n                foreach ($request['related'] as $related_id) {\n                    $error = $this->create_episode_relation($id, $related_id);\n                    if (is_wp_error($error)) {\n                        return $error;\n                    }\n                }\n            } else {\n                $related_id = $request['related'];\n                $error = $this->create_episode_relation($id, $related_id);\n                if (is_wp_error($error)) {\n                    return $error;\n                }\n            }\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $relation = EpisodeRelation::find_by_id($id);\n\n        if (!$relation) {\n            $msg = 'sorry, we do not found the episode relation with ID '.$id;\n\n            return new \\Podlove\\Api\\Error\\NotFound('rest_not_found', $msg);\n        }\n\n        if (isset($request['episode_id'])) {\n            $episode_id = $request['episode_id'];\n            $relation->left_episode_id = $episode_id;\n        }\n\n        if (isset($request['related_episode_id'])) {\n            $related_id = $request['related_episode_id'];\n            $relation->right_episode_id = $related_id;\n        }\n\n        $relation->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_items_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        if (isset($request['episode_id'])) {\n            $episode_id = $request['episode_id'];\n            $episode = Episode::find_by_id($episode_id);\n        }\n\n        if (isset($request['related_episode_id'])) {\n            $related_id = $request['related_episode_id'];\n            $related_episode = Episode::find_by_id($related_id);\n        }\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($episode->id);\n        }\n        if (!$related_episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($related_episode->id);\n        }\n\n        $error = $this->create_episode_relation($episode->id, $related_episode->id);\n        if (is_wp_error($error)) {\n            return $error;\n        }\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'relation_id' => $error\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_items($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($id);\n        }\n\n        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id);\n\n        foreach ($relations as $relation) {\n            $relation->delete();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $relation = EpisodeRelation::find_by_id($id);\n\n        if (!$relation) {\n            $msg = 'sorry, we do not found the episode relation with ID '.$id;\n\n            return new \\Podlove\\Api\\Error\\NotFound('rest_not_found', $msg);\n        }\n\n        $relation->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_items_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    private function create_episode_relation($id, $related_id)\n    {\n        // Don't create duplicates\n        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.intval($id).' AND right_episode_id = '.intval($related_id));\n        if ($relations) {\n            return;\n        }\n\n        $relations = EpisodeRelation::find_all_by_where('right_episode_id = '.intval($id).' AND left_episode_id = '.intval($related_id));\n        if ($relations) {\n            return;\n        }\n\n        $related_episode = Episode::find_by_id($related_id);\n        if (!$related_episode) {\n            return new \\Podlove\\Api\\Error\\NotFoundEpisode($related_id);\n        }\n        $relation = new EpisodeRelation();\n        $relation->left_episode_id = $id;\n        $relation->right_episode_id = $related_id;\n        $relation->save();\n\n        return $relation->id;\n    }\n}\n"
  },
  {
    "path": "includes/api/episodes.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Episodes;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\EpisodeAsset;\nuse Podlove\\Model\\MediaFile;\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Seasons;\nuse Podlove\\Modules\\Shows;\n\nadd_action('rest_api_init', __NAMESPACE__.'\\api_init');\n\nfunction api_init()\n{\n    register_rest_route('podlove/v1', 'episodes', [\n        'methods' => 'GET',\n        'callback' => __NAMESPACE__.'\\list_api',\n        'permission_callback' => '__return_true',\n    ]);\n\n    register_rest_route('podlove/v1', 'episodes/(?P<id>[\\d]+)', [\n        'methods' => 'GET',\n        'callback' => __NAMESPACE__.'\\episodes_api',\n        'permission_callback' => '__return_true',\n    ]);\n\n    register_rest_route('podlove/v1', 'episodes/(?P<id>[\\d]+)', [\n        'methods' => \\WP_REST_Server::EDITABLE,\n        'callback' => __NAMESPACE__.'\\episodes_update_api',\n        'permission_callback' => __NAMESPACE__.'\\update_episode_permission_check',\n    ]);\n}\n\nfunction list_api()\n{\n    $episodes = Episode::find_all_by_time([\n        'post_status' => 'publish',\n    ]);\n\n    $results = [];\n\n    foreach ($episodes as $episode) {\n        array_push($results, [\n            'id' => $episode->id,\n            'title' => get_the_title($episode->post_id),\n        ]);\n    }\n\n    return new \\WP_REST_Response([\n        'results' => $results,\n        '_version' => 'v1',\n    ]);\n}\n\nfunction episodes_api($request)\n{\n    $id = $request->get_param('id');\n    $episode = Episode::find_by_id($id);\n    $podcast = Podcast::get();\n    $post = get_post($episode->post_id);\n\n    return new \\WP_REST_Response([\n        '_version' => 'v1',\n        'id' => $id,\n        'slug' => $post->post_name,\n        'title' => get_the_title($episode->post_id),\n        'title_clean' => $episode->title,\n        'subtitle' => trim($episode->subtitle),\n        'summary' => trim($episode->summary),\n        'publicationDate' => mysql2date('c', $post->post_date),\n        'duration' => $episode->get_duration('full'),\n        'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(),\n        'link' => get_permalink($episode->post_id),\n        'chapters' => chapters($episode),\n        'audio' => \\podlove_pwp5_audio_files($episode, null),\n        'files' => \\podlove_pwp5_files($episode, null),\n        'content' => apply_filters('the_content', $post->post_content),\n        'number' => $episode->number,\n        'mnemonic' => $podcast->mnemonic.($episode->number < 100 ? '0' : '').($episode->number < 10 ? '0' : '').$episode->number,\n        'soundbite_start' => $episode->soundbite_start,\n        'soundbite_duration' => $episode->soundbite_duration,\n        'soundbite_title' => $episode->soundbite_title\n        // @todo: all media files\n    ]);\n}\n\n/**\n * Check permission for change.\n *\n * @param mixed $request\n */\nfunction update_episode_permission_check($request)\n{\n    if (!current_user_can('edit_posts')) {\n        return new \\WP_Error(\n            'rest_forbidden',\n            esc_html__('sorry, you do not have permissions to use this REST API endpoint'),\n            ['status' => 401]\n        );\n    }\n\n    return true;\n}\n\nfunction episodes_update_api($request)\n{\n    $id = $request->get_param('id');\n    $episode = Episode::find_by_id($id);\n\n    if (!$episode) {\n        return;\n    }\n\n    if (isset($request['soundbite_start'])) {\n        $start = $request['soundbite_start'];\n        if (preg_match('/\\d\\d:[0-5]\\d:[0-5]\\d?.?\\d?\\d?\\d/', $start)) {\n            $episode->soundbite_start = $start;\n        } else {\n            return;\n        }\n    }\n\n    if (isset($request['soundbite_duration'])) {\n        $duration = $request['soundbite_duration'];\n        if (preg_match('/\\d\\d:[0-5]\\d:[0-5]\\d?.?\\d?\\d?\\d/', $duration)) {\n            $episode->soundbite_duration = $duration;\n        } else {\n            return;\n        }\n    }\n\n    if (isset($request['soundbite_title'])) {\n        $title = $request['soundbite_title'];\n        $episode->soundbite_title = $title;\n    }\n\n    $episode->save();\n\n    return new \\WP_REST_Response(null, 200);\n}\n\nfunction chapters($episode = null)\n{\n    return array_map(function ($c) {\n        $c->title = html_entity_decode(trim($c->title));\n\n        return $c;\n    }, (array) json_decode($episode->get_chapters('json')));\n}\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_PodloveEpisode_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_PodloveEpisode_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'episodes';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base, [\n            [\n                'args' => [\n                    'status' => [\n                        'description' => __('The status parameter is used to filter the collection of episodes', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['publish', 'draft', 'all']\n                    ],\n                    'show' => [\n                        'description' => __('Filter by show slug.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string'\n                    ],\n                    'guid' => [\n                        'description' => __('Filter by guid.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string'\n                    ],\n                    'sort_by' => [\n                        'description' => __('Sort the list of episodes', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['post_id', 'post_date']\n                    ],\n                    'order_by' => [\n                        'description' => __('Ascending or descending order for sorting of the list of episodes', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['asc', 'desc', 'ASC', 'DESC']\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'get_items_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/build_slug', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n                'title' => [\n                    'type' => 'string'\n                ]\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'build_slug'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/freeze_slug', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'freeze_slug'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/unfreeze_slug', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'unfreeze_slug'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'guid' => [\n                        'description' => __('Globally unique id.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'title' => [\n                        'description' => __('Clear, concise name for your episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'subtitle' => [\n                        'description' => __('Single sentence describing the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'summary' => [\n                        'description' => __('A summary of the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'number' => [\n                        'description' => __('An epsiode number.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'slug' => [\n                        'description' => __('Episode media file slug.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'duration' => [\n                        'description' => __('Duration of the episode', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::timestamp'\n                    ],\n                    'type' => [\n                        'description' => __('Episode type. May be used by podcast clients.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['full', 'trailer', 'bonus']\n                    ],\n                    'cover' => [\n                        'description' => __('An url for the episode cover', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::episodeCover'\n                    ],\n                    'explicit' => [\n                        'description' => __('explicit content?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'boolean'\n                    ],\n                    'soundbite_start' => [\n                        'description' => __('Start value of podcast:soundbite tag', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::timestamp'\n                    ],\n                    'soundbite_duration' => [\n                        'description' => __('Duration value of podcast::soundbite tag', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::timestamp'\n                    ],\n                    'soundbite_title' => [\n                        'description' => __('Title for the podcast::soundbite tag', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string'\n                    ],\n                    'auphonic_production_id' => [\n                        'description' => 'Auphonic Production ID',\n                        'type' => 'string'\n                    ],\n                    'is_auphonic_production_running' => [\n                        'description' => 'Tracks if Auphonic production is running',\n                        'type' => 'boolean'\n                    ],\n                    'auphonic_webhook_config' => [\n                        'description' => 'Auphonic Webhook after Production is done',\n                        'type' => 'object',\n                        'properties' => [\n                            'authkey' => [\n                                'description' => 'Authentication key',\n                                'type' => 'string',\n                                'required' => 'true'\n                            ],\n                            'enabled' => [\n                                'description' => 'Publish episode when Production is done?',\n                                'type' => 'boolean',\n                                'required' => 'true'\n                            ]\n                        ]\n                    ],\n                    'show' => [\n                        'description' => 'Show slug. Assigns episode to given show.',\n                        'type' => 'string'\n                    ],\n                    'skip_validation' => [\n                        'description' => 'If true, mediafile validation is skipped on slug change.',\n                        'type' => 'boolean',\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/media/(?P<asset_id>[\\d]+)/enable', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n                'asset_id' => [\n                    'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_media_enable'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/media/(?P<asset_id>[\\d]+)/disable', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n                'asset_id' => [\n                    'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_media_disable'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/media/(?P<asset_id>[\\d]+)/verify', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n                'asset_id' => [\n                    'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_media_verify'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/media', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'args' => [\n                    'asset_id' => [\n                        'description' => __('Identifier of the asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'asset' => [\n                        'description' => __('Name of the asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'file_url' => [\n                        'description' => __('File url for the asset', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'enable' => [\n                        'description' => __('Is the asset used?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'boolean',\n                    ],\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_media'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)/tags', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'args' => [\n                    'term_id' => [\n                        'description' => __('Identifier of the term', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'name' => [\n                        'description' => __('Name of the term', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_tags'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_tags'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item_tags'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n    }\n\n    public function get_items_permissions_check($request)\n    {\n        $filter = $request->get_param('status');\n        if ($filter && ($filter == 'draft' || $filter == 'all') && (!current_user_can('edit_posts'))) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_items($request)\n    {\n        $filter = $request->get_param('status');\n        if (!$filter || ($filter != 'draft' && $filter != 'all')) {\n            $filter = 'publish';\n        }\n\n        $order_by = $request->get_param('order_by');\n        $sort_by = $request->get_param('sort_by');\n        $guid_filter = $request->get_param('guid');\n\n        $args = [];\n        if ($order_by) {\n            $args['order_by'] = $order_by;\n        }\n\n        if ($sort_by) {\n            if ($sort_by == 'post_id') {\n                $args['sort_by'] = 'ID';\n            } else {\n                $args['sort_by'] = $sort_by;\n            }\n        }\n\n        if ($filter != 'all') {\n            $args['post_status'] = $filter;\n        }\n\n        $show_slug = $request->get_param('show');\n        if ($show_slug) {\n            $show = Shows\\Model\\Show::find_one_term_by_property('slug', $show_slug);\n            if (!$show) {\n                return new \\Podlove\\Api\\Error\\NotFound('rest_not_found', 'There is no show with slug \"'.$show_slug.'\".');\n            }\n        }\n\n        $episodes = Episode::find_all_by_time($args);\n\n        $results = [];\n\n        foreach ($episodes as $episode) {\n            // filter by show slug\n            if ($show_slug) {\n                $show = Shows\\Model\\Show::find_one_by_episode_id($episode->id);\n                if (!$show || $show_slug != $show->slug) {\n                    continue;\n                }\n            }\n\n            // filter by guid\n            if ($guid_filter) {\n                if (get_the_guid($episode->post_id) != $guid_filter) {\n                    continue;\n                }\n            }\n\n            array_push($results, [\n                'id' => $episode->id,\n                'title' => get_the_title($episode->post_id),\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'results' => $results,\n            '_version' => 'v2',\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return false;\n        }\n\n        $post = $episode->post();\n        if (!$post) {\n            return false;\n        }\n\n        if ($post->post_status == 'publish' && $post->post_type == 'podcast') {\n            return true;\n        }\n\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_item($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n        $podcast = Podcast::get();\n        $post = get_post($episode->post_id);\n        $explicit = false;\n        if ($episode->explicit != 0) {\n            $explicit = true;\n        }\n\n        $postterms = get_the_terms($episode->post_id, 'shows');\n        $show = (is_array($postterms) && isset($postterms[0]) ? $postterms[0]->slug : '');\n\n        $data = [\n            '_version' => 'v2',\n            'id' => $id,\n            'guid' => get_the_guid($episode->post_id),\n            'slug' => $episode->slug,\n            'slug_frozen' => $episode->is_slug_frozen(),\n            'post_id' => $episode->post_id,\n            'title' => get_the_title($episode->post_id),\n            'title_clean' => $episode->title,\n            'subtitle' => trim($episode->subtitle ?? ''),\n            'summary' => trim($episode->summary ?? ''),\n            'duration' => $episode->get_duration('full'),\n            'type' => $episode->type,\n            'publicationDate' => mysql2date('c', $post->post_date),\n            'recording_date' => $episode->recording_date,\n            'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(),\n            'episode_poster' => $episode->cover_art,\n            'link' => get_permalink($episode->post_id),\n            'audio' => \\podlove_pwp5_audio_files($episode, null),\n            'files' => \\podlove_pwp5_files($episode, null),\n            'number' => $episode->number,\n            'mnemonic' => $podcast->mnemonic.($episode->number < 100 ? '0' : '').($episode->number < 10 ? '0' : '').$episode->number,\n            'soundbite_start' => $episode->soundbite_start,\n            'soundbite_duration' => $episode->soundbite_duration,\n            'soundbite_title' => $episode->soundbite_title,\n            'explicit' => $explicit,\n            'license_name' => $episode->license_name,\n            'license_url' => $episode->license_url,\n            'auphonic_production_id' => get_post_meta($episode->post_id, 'auphonic_production_id', true),\n            'is_auphonic_production_running' => get_post_meta($episode->post_id, 'is_auphonic_production_running', true),\n            'auphonic_plus_transfer_status' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_status', true),\n            'auphonic_plus_transfer_files' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_files', true),\n            'auphonic_plus_transfer_errors' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_errors', true),\n            'auphonic_plus_transfer_change_time' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_change_time', true),\n            'show' => $show\n        ];\n\n        $data = $this->enrich_with_season($data, $episode);\n\n        return new \\Podlove\\Api\\Response\\OkResponse($data);\n    }\n\n    public function get_item_media($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $assets = EpisodeAsset::all();\n\n        $results = array_map(function ($asset) use ($episode) {\n            $file = MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset->id);\n\n            return !$file ? [\n                'asset_id' => $asset->id,\n                'asset' => $asset->title,\n                'enable' => false,\n            ] : [\n                'asset_id' => $asset->id,\n                'asset' => $asset->title,\n                'url' => $file->get_file_url(),\n                'size' => $file->size,\n                'enable' => (bool) $file->active,\n            ];\n        }, $assets);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'results' => $results,\n        ]);\n    }\n\n    public function get_item_tags($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $post_id = $episode->post_id;\n\n        $post_tag_terms = wp_get_object_terms($post_id, 'post_tag');\n        if (!empty($post_tag_terms) && !is_wp_error($post_tag_terms)) {\n            $results = array_map(function ($tags) {\n                return [\n                    'term_id' => $tags->term_id,\n                    'name' => $tags->name\n                ];\n            }, $post_tag_terms);\n\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'tags' => $results\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'tags' => []\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        // create a post (only as draft)\n        $new_post = [\n            'post_title' => 'API created Podcast-Post',\n            'post_type' => 'podcast',\n            'post_status' => 'draft'\n        ];\n        $post_id = wp_insert_post($new_post);\n        if ($post_id) {\n            // create an episode with the created post\n            $episode = Episode::find_or_create_by_post_id($post_id);\n            $url = sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $episode->id);\n            $message = sprintf('Episode successfully created with id %d', $episode->id);\n            $data = [\n                'message' => $message,\n                'location' => $url,\n                'id' => $episode->id\n            ];\n            $headers = [\n                'location' => $url\n            ];\n\n            return new \\Podlove\\Api\\Response\\CreateResponse($data, $headers);\n        }\n\n        return new \\WP_REST_Response(null, 500);\n    }\n\n    public function build_slug($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $title = $request->get_param('title') ?? get_the_title($episode->post_id);\n\n        $slug = sanitize_title($title);\n\n        return new \\Podlove\\Api\\Response\\CreateResponse(['slug' => $slug]);\n    }\n\n    public function freeze_slug($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode->freeze_slug();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'slug_frozen' => $episode->is_slug_frozen()\n        ]);\n    }\n\n    public function unfreeze_slug($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode->unfreeze_slug();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'slug_frozen' => $episode->is_slug_frozen()\n        ]);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        $isSlugSet = false;\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['guid'])) {\n            update_post_meta($episode->post_id, '_podlove_guid', $request['guid']);\n        }\n\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $episode->title = $title;\n            $post_update = [\n                'ID' => $episode->post_id,\n                'post_title' => $title\n            ];\n            wp_update_post($post_update);\n        }\n\n        if (isset($request['subtitle'])) {\n            $subtitle = $request['subtitle'];\n            $episode->subtitle = $subtitle;\n        }\n\n        if (isset($request['summary'])) {\n            $summary = $request['summary'];\n            $episode->summary = $summary;\n        }\n\n        if (isset($request['number'])) {\n            $number = $request['number'];\n            $episode->number = $number;\n        }\n\n        if (isset($request['explicit'])) {\n            $explicit = $request['explicit'];\n            if (is_string($explicit)) {\n                $explicit_lowercase = strtolower($explicit);\n                if ($explicit_lowercase == 'true') {\n                    $episode->explicit = 1;\n                } elseif ($explicit_lowercase == 'false') {\n                    $episode->explicit = 0;\n                }\n            } else {\n                if ($explicit) {\n                    $episode->explicit = 1;\n                } else {\n                    $episode->explicit = 0;\n                }\n            }\n        }\n\n        if (isset($request['slug'])) {\n            $slug = trim($request['slug']);\n            // Only allow slug changes if not frozen\n            if (!$episode->is_slug_frozen()) {\n                $episode->slug = $slug;\n                $isSlugSet = true;\n            }\n        }\n\n        if (isset($request['duration'])) {\n            $duration = $request['duration'];\n            $episode->duration = $duration;\n        }\n\n        if (isset($request['recording_date'])) {\n            $recording_date = $request['recording_date'];\n            $episode->recording_date = $recording_date;\n        }\n\n        if (isset($request['type'])) {\n            $type = $request['type'];\n            $episode->type = $type;\n        }\n\n        if (isset($request['episode_poster'])) {\n            $episode_poster = $request['episode_poster'];\n            $episode->cover_art = $episode_poster;\n        }\n\n        if (isset($request['soundbite_start'])) {\n            $start = $request['soundbite_start'];\n            $episode->soundbite_start = $start;\n        }\n\n        if (isset($request['soundbite_duration'])) {\n            $duration = $request['soundbite_duration'];\n            $episode->soundbite_duration = $duration;\n        }\n\n        if (isset($request['soundbite_title'])) {\n            $title = $request['soundbite_title'];\n            $episode->soundbite_title = $title;\n        }\n\n        if (isset($request['license_name'])) {\n            $license_name = $request['license_name'];\n            $episode->license_name = $license_name;\n        }\n\n        if (isset($request['license_url'])) {\n            $license_url = $request['license_url'];\n            $episode->license_url = $license_url;\n        }\n\n        if (isset($request['auphonic_production_id'])) {\n            update_post_meta($episode->post_id, 'auphonic_production_id', $request['auphonic_production_id']);\n        }\n\n        if (isset($request['is_auphonic_production_running'])) {\n            update_post_meta($episode->post_id, 'is_auphonic_production_running', $request['is_auphonic_production_running']);\n        }\n\n        if (isset($request['show'])) {\n            Shows\\Shows::set_show_for_episode($episode->post_id, $request['show']);\n        }\n\n        $episode->save();\n\n        // DEPRECATED: clients should validate themselves. Remove in v3.\n        if ($isSlugSet && !$request['skip_validation']) {\n            $assets = EpisodeAsset::all();\n\n            foreach ($assets as $asset) {\n                $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset->id);\n                $file->determine_file_size();\n                $file->save(false);\n            }\n        }\n\n        \\podlove_clear_feed_cache_for_post($episode->post_id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_media_enable($request)\n    {\n        $asset_id = $request['asset_id'];\n        $episode = $this->get_episode_from_request($request);\n\n        if (is_wp_error($episode)) {\n            return $episode;\n        }\n\n        $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset_id);\n        $file->determine_file_size();\n        $file->active = true;\n        $file->save();\n\n        if ($file->size == 0) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'message' => 'file size cannot be determined',\n                'active' => $file->active,\n                'status' => 'ok'\n            ]);\n        }\n        do_action('podlove_media_file_content_verified', $file->id);\n\n        // refetch because episode may have been edited by action\n        $episode = $this->get_episode_from_request($request);\n\n        \\podlove_clear_feed_cache_for_post($episode->post_id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'file_size' => $file->size,\n            'file_url' => $file->get_file_url(),\n            'active' => $file->active,\n            'slug_frozen' => $episode->is_slug_frozen(),\n        ]);\n    }\n\n    public function update_item_media_disable($request)\n    {\n        $asset_id = $request['asset_id'];\n        $episode = $this->get_episode_from_request($request);\n\n        if (is_wp_error($episode)) {\n            return $episode;\n        }\n\n        $file = MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset_id);\n        if ($file) {\n            $file->active = false;\n            $file->save();\n        } else {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        \\podlove_clear_feed_cache_for_post($episode->post_id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'file_size' => $file->size,\n            'file_url' => $file->get_file_url(),\n            'active' => $file->active,\n        ]);\n    }\n\n    public function update_item_media_verify($request)\n    {\n        $asset_id = $request['asset_id'];\n        $episode = $this->get_episode_from_request($request);\n\n        if (is_wp_error($episode)) {\n            return $episode;\n        }\n\n        $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset_id);\n        $file->determine_file_size();\n        $file->save(false);\n\n        if ($file->size == 0) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'status' => 'ok',\n                'message' => 'file size cannot be determined',\n                'file_url' => $file->get_file_url(),\n                'active' => $file->active,\n                'slug_frozen' => $episode->is_slug_frozen(),\n            ]);\n        }\n        do_action('podlove_media_file_content_verified', $file->id);\n\n        // refetch because episode may have been edited by action\n        $episode = $this->get_episode_from_request($request);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok',\n            'file_size' => $file->size,\n            'file_url' => $file->get_file_url(),\n            'active' => $file->active,\n            'slug_frozen' => $episode->is_slug_frozen(),\n        ]);\n    }\n\n    public function update_item_tags($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $post_id = $episode->post_id;\n\n        if (isset($request['term_id'])) {\n            $terms = $request['term_id'];\n            if (is_array($terms)) {\n                $term_ids = array_map(function ($term) {\n                    return intval($term);\n                }, $terms);\n            } else {\n                $term_ids = intval($terms);\n            }\n            $val = wp_set_object_terms($post_id, $term_ids, 'post_tag', true);\n            if (is_wp_error($val)) {\n                return new \\Podlove\\Api\\Error\\InternalServerError(500, $val->message);\n            }\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        wp_trash_post($episode->post_id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_tags($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $post_id = $episode->post_id;\n\n        $val = wp_set_object_terms($post_id, [], 'post_tag', false);\n\n        if (is_wp_error($val)) {\n            return new \\Podlove\\Api\\Error\\InternalServerError();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    private function get_episode_from_request($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        return $episode;\n    }\n\n    private function enrich_with_season($data, Episode $episode)\n    {\n        if (!\\Podlove\\Modules\\Base::is_active('seasons')) {\n            return $data;\n        }\n\n        $season = Seasons\\Model\\Season::for_episode($episode);\n        if (!$season) {\n            return $data;\n        }\n\n        $data['season_id'] = (int) $season->id;\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "includes/api/feeds.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Feeds;\n\nuse Podlove\\Model\\Feed;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_PodloveFeed_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_PodloveFeed_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'feeds';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'get_items_permissions_check'],\n            ]\n        ]);\n    }\n\n    /**\n     * Check if current user has permission to get the list of feeds.\n     *\n     * @param \\WP_REST_Request $request\n     *\n     * @return bool|\\WP_Error\n     */\n    public function get_items_permissions_check($request)\n    {\n        // Anyone can read the feeds list\n        return true;\n    }\n\n    /**\n     * Get a list of all feeds.\n     *\n     * @param \\WP_REST_Request $request\n     *\n     * @return \\Podlove\\Api\\Response\\OkResponse\n     */\n    public function get_items($request)\n    {\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'results' => self::get_feeds()\n        ]);\n    }\n\n    public static function get_feeds($taxonomy = null, $term_id = null)\n    {\n        $feeds = Feed::find_all_by_property('enable', 1);\n\n        $results = [];\n\n        foreach ($feeds as $feed) {\n            // Skip protected and non-discoverable feeds\n            if ($feed->protected || !$feed->discoverable) {\n                continue;\n            }\n\n            $episode_asset = $feed->episode_asset();\n            $file_type = $episode_asset ? $episode_asset->file_type() : null;\n\n            $result = [\n                'id' => $feed->id,\n                'title' => $feed->get_title(),\n                'url' => $feed->get_subscribe_url($taxonomy, $term_id),\n                'content_type' => $feed->get_content_type()\n            ];\n\n            if ($file_type) {\n                $result['file_type'] = [\n                    'name' => $file_type->name,\n                    'extension' => $file_type->extension,\n                    'mime_type' => $file_type->mime_type\n                ];\n            }\n\n            $results[] = $result;\n        }\n\n        return $results;\n    }\n}\n"
  },
  {
    "path": "includes/api/podcast.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Podcast;\n\nuse Podlove\\Model\\Podcast;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_Podlove_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_Podlove_Controller extends \\WP_REST_Controller\n{\n    /**\n     * Constructor.\n     */\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'podcast';\n    }\n\n    /**\n     * Register the component routes.\n     */\n    public function register_routes()\n    {\n        $categories = \\Podlove\\Itunes\\categories(false);\n        $categories_val = array_values($categories);\n        $categories_enum = array_map(function ($val) {\n            return str_replace('&', 'and', $val);\n        }, $categories_val);\n\n        $locales = \\Podlove\\Locale\\locales();\n        $locales_enum = array_keys($locales);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n                'args' => [\n                    'guid' => [\n                        'description' => __('Unique, global identifier for a podcast', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'title' => [\n                        'description' => __('Title of the podcast', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'subtitle' => [\n                        'description' => __('Extension to the title. Clarify what the podcast is about.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'summary' => [\n                        'description' => __('Elaborate description of the podcasts content.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'author_name' => [\n                        'description' => __('Name of the podcast author. Publicly displayed in Podcast directories.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'cover_image' => [\n                        'description' => __('Cover art for the podcast', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::url'\n                    ],\n                    'podcast_email' => [\n                        'description' => __('Used by iTunes and other Podcast directories to contact you.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => 'is_email',\n                    ],\n                    'mnemonic' => [\n                        'description' => __('Abbreviation for your podcast.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'funding_url' => [\n                        'description' => __('Can be used by podcatchers show funding/donation links for the podcast.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::url'\n                    ],\n                    'funding_label' => [\n                        'description' => __('Label for funding/donation URL.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'copyright' => [\n                        'description' => __('Copyright notice for content in the channel.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'explicit' => [\n                        'description' => __('Is the overall content of the podcast explicit?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'boolean',\n                    ],\n                    'category' => [\n                        'description' => __('iTunes category of the podcast', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => $categories_enum,\n                    ],\n                    'language' => [\n                        'description' => __('The language that is spoken in the podcast.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => $locales_enum,\n                    ]\n                ]\n            ]\n        ]);\n    }\n\n    /**\n     * Check permission for read.\n     *\n     * @param mixed $request\n     */\n    public function get_item_permissions_check($request)\n    {\n        return true;\n    }\n\n    /**\n     * Check permission for change.\n     *\n     * @param mixed $request\n     */\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_item($request)\n    {\n        $podcast = Podcast::get();\n\n        $explicit = false;\n        if ($podcast->explicit != 0) {\n            $explicit = true;\n        }\n\n        $feeds = $podcast->feeds(['only_discoverable' => true]);\n        $feed_urls = array_map(function ($feed) {\n            return [\"{$feed->slug}\" => $feed->get_subscribe_url()];\n        }, $feeds);\n\n        $res = [];\n        $res['_version'] = 'v2';\n        $res['guid'] = $podcast->guid;\n        $res['title'] = $podcast->title;\n        $res['subtitle'] = $podcast->subtitle;\n        $res['summary'] = $podcast->summary;\n        $res['mnemonic'] = $podcast->mnemonic;\n        $res['itunes_type'] = $podcast->itunes_type;\n        $res['author_name'] = $podcast->author_name;\n        $res['podcast_email'] = $podcast->owner_email;\n        $res['poster'] = $podcast->cover_art()->setWidth(500)->url();\n        $res['link'] = \\Podlove\\get_landing_page_url();\n        $res['funding_url'] = $podcast->funding_url;\n        $res['funding_label'] = $podcast->funding_label;\n        if (!$podcast->copyright) {\n            $res['copyright'] = $podcast->default_copyright_claim();\n        } else {\n            $res['copyright'] = $podcast->copyright;\n        }\n        $res['explicit'] = $explicit;\n        $res['category'] = $this->getCategoryName($podcast->category_1);\n        $res['language'] = $this->getLanguageName($podcast->language);\n        $res['license_url'] = $podcast->license_url;\n        $res['license_name'] = $podcast->license_name;\n        $res['feeds'] = $feed_urls;\n\n        $res = apply_filters('podlove_api_podcast_response', $res);\n\n        return new \\Podlove\\Api\\Response\\OkResponse($res);\n    }\n\n    public function update_item($request)\n    {\n        $podcast = Podcast::get();\n        if (isset($request['guid'])) {\n            $guid = $request['guid'];\n            $podcast->guid = $guid;\n        }\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $podcast->title = $title;\n        }\n        if (isset($request['subtitle'])) {\n            $subtitle = $request['subtitle'];\n            $podcast->subtitle = $subtitle;\n        }\n        if (isset($request['summary'])) {\n            $summary = $request['summary'];\n            $podcast->summary = $summary;\n        }\n        if (isset($request['mnemonic'])) {\n            $mnemonic = $request['mnemonic'];\n            $podcast->mnemonic = $mnemonic;\n        }\n        if (isset($request['author_name'])) {\n            $author = $request['author_name'];\n            $podcast->author_name = $author;\n        }\n        if (isset($request['cover_image'])) {\n            $cover = $request['cover_image'];\n            $podcast->cover_image = $cover;\n        }\n        if (isset($request['podcast_email'])) {\n            $podcast_email = $request['podcast_email'];\n            $podcast->owner_email = $podcast_email;\n        }\n        if (isset($request['funding_url'])) {\n            $funding_url = $request['funding_url'];\n            $podcast->funding_url = $funding_url;\n        }\n        if (isset($request['funding_label'])) {\n            $funding_label = $request['funding_label'];\n            $podcast->funding_label = $funding_label;\n        }\n        if (isset($request['copyright'])) {\n            $copyright = $request['copyright'];\n            $podcast->copyright = $copyright;\n        }\n        if (isset($request['explicit'])) {\n            $explicit = $request['explicit'];\n            $explicit_lowercase = strtolower($explicit);\n            if ($explicit_lowercase == 'false') {\n                $podcast->explicit = 0;\n            } elseif ($explicit_lowercase == 'true') {\n                $podcast->explicit = 1;\n            }\n        }\n        if (isset($request['category'])) {\n            $category = $request['category'];\n            $category = str_replace('and', '&', $category);\n            $category_key = $this->getCategoryKey($category);\n            if ($category_key) {\n                $podcast->category_1 = $category_key;\n            }\n        }\n        if (isset($request['language'])) {\n            $language = $request['language'];\n            $podcast->language = $language;\n        }\n        if (isset($request['license_url'])) {\n            $license_url = $request['license_url'];\n            $podcast->license_url = $license_url;\n        }\n        if (isset($request['license_name'])) {\n            $license_name = $request['license_name'];\n            $podcast->license_name = $license_name;\n        }\n\n        $podcast->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    private function getCategoryKey($category)\n    {\n        $categories = \\Podlove\\Itunes\\categories(false);\n        foreach ($categories as $key => $val) {\n            if ($val == $category) {\n                return $key;\n            }\n        }\n    }\n\n    private function getCategoryName($category_key)\n    {\n        $categories = \\Podlove\\Itunes\\categories(true);\n        foreach ($categories as $key => $val) {\n            if ($key == $category_key) {\n                return $val;\n            }\n        }\n\n        return '';\n    }\n\n    private function getLanguageName($language_key)\n    {\n        $language = \\Podlove\\Locale\\locales();\n        foreach ($language as $key => $val) {\n            if ($key == $language_key) {\n                return $val;\n            }\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "includes/api/show.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Show;\n\nuse Podlove\\Model\\Podcast;\n\nadd_action('rest_api_init', __NAMESPACE__.'\\api_init');\n\nfunction api_init()\n{\n    // FIXME: why is this called \"show\", not \"podcast\"? 🤔\n    register_rest_route('podlove/v1', 'show', [\n        'methods' => 'GET',\n        'callback' => __NAMESPACE__.'\\show_api',\n        'permission_callback' => '__return_true',\n    ]);\n}\n\nfunction show_api()\n{\n    $podcast = Podcast::get();\n\n    $response = [\n        '_version' => 'v1',\n        'title' => $podcast->title,\n        'subtitle' => $podcast->subtitle,\n        'summary' => $podcast->summary,\n        'mnemonic' => $podcast->mnemonic,\n        'itunes_type' => $podcast->itunes_type,\n        'author_name' => $podcast->author_name,\n        'poster' => $podcast->cover_art()->setWidth(500)->url(),\n        'link' => \\Podlove\\get_landing_page_url(),\n    ];\n\n    $response = apply_filters('podlove_api_podcast_response', $response);\n\n    return new \\WP_REST_Response($response);\n}\n"
  },
  {
    "path": "includes/api/tools.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Tools;\n\nadd_action('rest_api_init', function () {\n    $controller = new WP_REST_Podlove_Tools_Controller();\n    $controller->register_routes();\n});\n\nclass WP_REST_Podlove_Tools_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'tools';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/clear-caches', [\n            'methods' => \\WP_REST_Server::DELETABLE,\n            'callback' => [$this, 'clear_caches'],\n            'permission_callback' => [$this, 'clear_caches_permission_check']\n        ]);\n    }\n\n    public function clear_caches($request)\n    {\n        \\Podlove\\Repair::clear_podlove_cache();\n        \\Podlove\\Repair::clear_podlove_image_cache();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function clear_caches_permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "includes/auto_post_titles.php",
    "content": "<?php\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Podcast;\n\nadd_filter('the_title', 'podlove_maybe_override_post_titles', 10, 2);\nadd_filter('podlove_get_episode_title', 'podlove_maybe_override_post_titles', 10, 2);\nadd_filter('podlove_get_episode_title_rss', 'podlove_maybe_override_rss_post_titles', 10, 2);\nadd_action('admin_print_scripts', 'podlove_override_post_title_script');\n\nfunction podlove_maybe_override_post_titles($original_title, $post_id = null)\n{\n    if (is_null($post_id)) {\n        $post_id = get_the_ID();\n    }\n\n    if (get_post_type($post_id) !== 'podcast') {\n        return $original_title;\n    }\n\n    if (!podlove_is_title_autogen_enabled()) {\n        return $original_title;\n    }\n\n    $episode_title = podlove_generated_post_title($post_id);\n\n    if ($episode_title) {\n        return $episode_title;\n    }\n\n    return $original_title;\n}\n\nfunction podlove_maybe_override_rss_post_titles($original_title)\n{\n    $post_id = get_the_ID();\n    $podcast = Podcast::get();\n\n    if (get_post_type($post_id) !== 'podcast') {\n        return $original_title;\n    }\n\n    switch ($podcast->get_feed_episode_title_variant()) {\n        case 'blog':\n            return $original_title;\n\n            break;\n        case 'episode':\n            $episode = Episode::find_one_by_post_id($post_id);\n\n            if ($episode && $episode->title) {\n                return trim(wp_strip_all_tags($episode->title));\n            }\n\n            return $original_title;\n\n            break;\n        case 'template':\n            $title = podlove_generated_feed_post_title($post_id);\n            if ($title) {\n                return $title;\n            }\n\n            return $original_title;\n\n            break;\n\n        default:\n            return $original_title;\n    }\n}\n\nfunction podlove_generated_post_title($post_id)\n{\n    $template = \\Podlove\\get_setting('website', 'blog_title_template');\n\n    return podlove_get_episode_title_by_template($post_id, $template);\n}\n\nfunction podlove_generated_feed_post_title($post_id)\n{\n    $template = Podcast::get()->get_feed_episode_title_template();\n\n    return podlove_get_episode_title_by_template($post_id, $template);\n}\n\nfunction podlove_get_episode_title_by_template($post_id, $template)\n{\n    $episode = Episode::find_one_by_post_id($post_id);\n\n    if (!$template || !$episode) {\n        return false;\n    }\n\n    $title = $template;\n    $title = str_replace('%mnemonic%', wp_strip_all_tags(podlove_get_mnemonic($post_id)), $title);\n    $title = str_replace('%episode_number%', $episode->number_padded(), $title);\n\n    $episode_title = trim(wp_strip_all_tags((string) $episode->title));\n\n    if (!$episode_title) {\n        $episode_title = get_post($post_id)->post_title;\n    }\n\n    $title = str_replace('%episode_title%', $episode_title, $title);\n\n    $title = apply_filters('podlove_generated_post_title', $title, $episode);\n\n    return trim($title);\n}\n\nfunction podlove_override_post_title_script()\n{\n    if (!\\Podlove\\is_episode_edit_screen()) {\n        return;\n    }\n\n    $data = [\n        'enabled' => podlove_is_title_autogen_enabled(),\n        'template' => \\Podlove\\get_setting('website', 'blog_title_template'),\n        'episode_padding' => \\Podlove\\get_setting('website', 'episode_number_padding'),\n        'mnemonic' => podlove_get_mnemonic(),\n        'placeholder' => __('Fill in episode title below', 'podlove-podcasting-plugin-for-wordpress'),\n    ];\n\n    $data = apply_filters('podlove_js_data_for_post_title', $data, get_the_ID());\n    ?>\n<script type=\"text/javascript\">\nvar PODLOVE = PODLOVE || {};\nPODLOVE.override_post_title = <?php echo wp_json_encode($data); ?>;\n</script>\n<?php\n}\n\nfunction podlove_get_mnemonic($post_id = null)\n{\n    $podcast = Podcast::get();\n\n    return $podcast->mnemonic;\n}\n\nfunction podlove_is_title_autogen_enabled()\n{\n    return (bool) \\Podlove\\get_setting('website', 'enable_generated_blog_post_title');\n}\n"
  },
  {
    "path": "includes/cache.php",
    "content": "<?php\n\nuse Podlove\\Model;\n\n// devalidate caches when media file has changed\nadd_action('podlove_media_file_content_has_changed', function ($media_file_id) {\n    if ($media_file = Model\\MediaFile::find_by_id($media_file_id)) {\n        if ($episode = $media_file->episode()) {\n            $episode->delete_caches();\n        }\n    }\n});\n\n// devalidate caches when episode content has changed\nadd_action('podlove_episode_content_has_changed', function ($episode_id) {\n    if ($episode = Model\\Episode::find_by_id($episode_id)) {\n        $episode->delete_caches();\n    }\n});\n\nfunction podlove_clear_feed_cache_for_post($post_id)\n{\n    $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n\n    foreach (Model\\Feed::all() as $feed) {\n        if ($feed->slug) {\n            $cache_key = 'feed_item_'.$feed->slug.'_'.$post_id;\n            $cache->delete_cache_for($cache_key);\n        }\n    }\n}\n"
  },
  {
    "path": "includes/capabilities.php",
    "content": "<?php\n\n/**\n * Capabilities.\n *\n * - podlove_read_analytics: can view analytics\n * - podlove_read_dashboard: can view analytics\n * - podlove_manage_contributors: can manage contributors\n */\n\n/**\n * Initialize Capabilities.\n */\nfunction podlove_init_capabilities()\n{\n    podlove_add_capability_to_roles('podlove_read_analytics', ['administrator', 'editor', 'author']);\n    podlove_add_capability_to_roles('podlove_read_dashboard', ['administrator', 'editor', 'author']);\n\n    podlove_add_capability_to_roles('podlove_manage_contributors', ['administrator']);\n}\n\n/**\n * Add capability to a list of roles.\n *\n * @param string $capability wordPress capability\n * @param array  $roles      list of roles\n */\nfunction podlove_add_capability_to_roles($capability, $roles = [])\n{\n    foreach ($roles as $role_name) {\n        if ($role = get_role($role_name)) {\n            $role->add_cap($capability);\n        }\n    }\n}\n"
  },
  {
    "path": "includes/chapters.php",
    "content": "<?php\nuse Podlove\\Model;\n\n/*\n * Enable chapters pages\n *\n * add ?chapters_format=psc|json|mp4chaps|pijson to any episode URL to get chapters\n */\nadd_action('wp', function () {\n    if (!is_single()) {\n        return;\n    }\n\n    $chapters_format = filter_input(INPUT_GET, 'chapters_format', FILTER_VALIDATE_REGEXP, [\n        'options' => ['regexp' => '/^(psc|pijson|json|mp4chaps)$/'],\n    ]);\n\n    if (!$chapters_format) {\n        return;\n    }\n\n    if (!$episode = Model\\Episode::find_one_by_post_id(get_the_ID())) {\n        return;\n    }\n\n    switch ($chapters_format) {\n        case 'psc':\n            header('Content-Type: application/xml');\n            echo '<?xml version=\"1.0\" encoding=\"UTF-8\"?>'.\"\\n\";\n\n            break;\n        case 'mp4chaps':\n            header('Content-Type: text/plain');\n\n            break;\n        case 'json':\n        case 'pijson':\n            header('Content-Type: application/json');\n\n            break;\n    }\n\n    echo $episode->get_chapters($chapters_format);\n    exit;\n});\n\n/*\n * When changing from an external chapter asset to 'manual', copy external\n * contents into local field.\n */\nadd_filter('pre_update_option_podlove_asset_assignment', function ($new, $old) {\n    global $wpdb;\n\n    if (!isset($old['chapters']) || !isset($new['chapters'])) {\n        return $new;\n    }\n\n    if ($new['chapters'] != 'manual') {  // just changes to manual\n        return $new;\n    }\n\n    if (((int) $old['chapters']) <= 0) { // just changes from an asset\n        return $new;\n    }\n\n    $episodes = \\Podlove\\Model\\Episode::find_all_by_time();\n\n    // 10 seconds per episode or 30 seconds since 1 request per asset\n    // is required if it is not cached\n    set_time_limit(max(30, count($episodes) * 10));\n\n    foreach ($episodes as $episode) {\n        if ($chapters = $episode->get_chapters('mp4chaps')) {\n            $episode->update_attribute('chapters', $chapters);\n        }\n    }\n\n    // delete chapters caches\n    $wpdb->query('DELETE FROM `'.$wpdb->options.'` WHERE option_name LIKE \"%podlove_chapters_string_%\"');\n\n    return $new;\n}, 10, 2);\n\n// extend episode form\nadd_filter('podlove_episode_form_data', function ($form_data, $episode) {\n    if (Model\\AssetAssignment::get_instance()->chapters !== 'manual') {\n        return $form_data;\n    }\n\n    $form_data[] = [\n        'type' => 'callback',\n        'key' => 'chapters',\n        'options' => [\n            'callback' => function () {\n                ?>\n  <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n    <podlove-chapters></podlove-chapters>\n  </div>\n<?php\n            }\n        ],\n        'position' => 800,\n    ];\n\n    return $form_data;\n}, 10, 2);\n\n// add PSC & podcast index json to RSS feed\nadd_action('podlove_append_to_feed_entry', function ($podcast, $episode, $feed, $format) {\n    // PSC\n    $chapters = new \\Podlove\\Feeds\\Chapters($episode);\n    $chapters->render('inline');\n\n    // podcastindex\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement('podcast:chapters');\n\n    $url = $episode->permalink().'?chapters_format=pijson';\n    $attr = $doc->createAttribute('url');\n    $attr->value = esc_attr($url);\n    $node->appendChild($attr);\n\n    $attr2 = $doc->createAttribute('type');\n    $attr2->value = 'application/json+chapters';\n    $node->appendChild($attr2);\n\n    echo \"\\n\".$doc->saveXML($node);\n}, 10, 4);\n"
  },
  {
    "path": "includes/compatibility.php",
    "content": "<?php\n\n/**\n * Detect plugin updates and flush rewrite rules.\n *\n * There is a known issue where upgrading YOAST SEO breaks permalinks. This\n * function detects the update and schedules a flush of rewrite rules. We flush\n * immediately on update and schedule a one-time flush for auto-updates.\n *\n * @param object $upgrader_object the upgrader object\n * @param array  $options         the options array\n */\nfunction podlove_detect_plugin_updates($upgrader_object, $options)\n{\n    if ($options['action'] != 'update' || $options['type'] != 'plugin') {\n        return;\n    }\n\n    foreach ($options['plugins'] as $plugin) {\n        if (strpos($plugin, 'wordpress-seo') !== false) {\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n            if (!wp_next_scheduled('podlove_flush_rewrite_rules')) {\n                wp_schedule_single_event(time() + 3, 'podlove_flush_rewrite_rules');\n            }\n\n            break;\n        }\n    }\n}\n\nfunction podlove_do_flush_rewrite_rules()\n{\n    flush_rewrite_rules();\n}\n\nadd_action('upgrader_process_complete', 'podlove_detect_plugin_updates', 10, 2);\nadd_action('podlove_flush_rewrite_rules', 'podlove_do_flush_rewrite_rules');\n"
  },
  {
    "path": "includes/db_migration.php",
    "content": "<?php\n\n/**\n * Execute migration query. Captures error if one occurs.\n *\n * @param string $sql\n */\nfunction podlove_do_migration_query($sql)\n{\n    global $wpdb;\n\n    $success = $wpdb->query($sql);\n\n    if ($success === false) {\n        update_option('podlove_db_migration_error', [\n            'error' => $wpdb->last_error,\n            'query' => $wpdb->last_query,\n        ]);\n    }\n\n    return (bool) $success;\n}\n\nadd_action('admin_notices', 'podlove_show_database_migration_error');\n\nfunction podlove_show_database_migration_error()\n{\n    $data = get_option('podlove_db_migration_error');\n\n    if (!$data || !isset($data['error']) || !isset($data['query'])) {\n        return;\n    }\n\n    if (isset($_REQUEST['podlove_hide_migration_error']) && $_REQUEST['podlove_hide_migration_error']) {\n        delete_option('podlove_db_migration_error');\n\n        return;\n    }\n\n    ?>\n  <div class=\"notice notice-error\">\n    <p>\n      <?php echo __('An error occurred during a Podlove Podcast Publisher database migration.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n    </p>\n    <p>\n    <?php echo __('Error', 'podlove-podcasting-plugin-for-wordpress'); ?>: <code><?php echo esc_html($data['error']); ?></code>\n    </p>\n    <p>\n    <?php echo __('Query', 'podlove-podcasting-plugin-for-wordpress'); ?>: <code><?php echo esc_html($data['query']); ?></code>\n    </p>\n    <p>\n      <?php echo sprintf(\n          __('The plugin might not fully work until this is resolved. If you do not know what to do, ask for help in the forums: %s', 'podlove-podcasting-plugin-for-wordpress'),\n          '<a href=\"https://community.podlove.org/\" target=\"_blank\">community.podlove.org</a>'\n      ); ?>\n    </p>\n    <p>\n      <a href=\"<?php echo podlove_hide_migration_error_url(); ?>\"><?php echo __('hide this message', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n      </p>\n  </div>\n  <?php\n}\n\nfunction podlove_hide_migration_error_url()\n{\n    if (isset($_REQUEST['page']) && $_REQUEST['page']) {\n        return admin_url('admin.php?page='.$_REQUEST['page'].'&podlove_hide_migration_error=1');\n    }\n\n    return admin_url('?podlove_hide_migration_error=1');\n}\n"
  },
  {
    "path": "includes/deprecations.php",
    "content": "<?php\nadd_action('admin_notices', 'podlove_init_deprecation_checker');\n\nfunction podlove_init_deprecation_checker()\n{\n    // dashboard only\n    if (filter_input(INPUT_GET, 'page') !== 'podlove_settings_handle') {\n        return;\n    }\n\n    $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n    $deprecations = $cache->cache_for('podlove_template_deprecations', function () {\n        return podlove_get_deprecations();\n    });\n\n    podlove_render_deprecations($deprecations);\n}\n\nfunction podlove_get_template_deprecations()\n{\n    $deprecations = [];\n\n    $deprecation_matcher = [\n        'shortcode' => [\n            'data' => podlove_get_deprecated_shortcodes(),\n            'matcher' => array_keys(podlove_get_deprecated_shortcodes()),\n        ],\n        'template tag' => [\n            'data' => podlove_get_deprecated_template_tags(),\n            'matcher' => array_keys(podlove_get_deprecated_template_tags()),\n        ],\n    ];\n\n    foreach (\\Podlove\\Model\\Template::all() as $template) {\n        foreach ($deprecation_matcher as $deprecated_type => $matcher) {\n            foreach ($matcher['matcher'] as $regex) {\n                if ($template->content) {\n                    if (preg_match('/'.$regex.'/', $template->content, $matches)) {\n                        $deprecations[] = [\n                            'context' => ['type' => 'template', 'id' => $template->id],\n                            'deprecated' => [\n                                'type' => $deprecated_type,\n                                'content' => $matches[0],\n                            ],\n                            'instead' => $matcher['data'][$regex],\n                        ];\n                    }\n                }\n            }\n        }\n    }\n\n    return $deprecations;\n}\n\nfunction podlove_get_episodes_deprecations()\n{\n    $deprecations = [];\n\n    $shortcodes_data = podlove_get_deprecated_shortcodes();\n    $shortcode_matcher = array_keys($shortcodes_data);\n\n    $query = new \\WP_Query(['post_type' => 'podcast']);\n    while ($query->have_posts()) {\n        $post = $query->next_post();\n\n        foreach ($shortcode_matcher as $shortcode) {\n            if (preg_match('/'.$shortcode.'/', $post->post_content, $matches)) {\n                $deprecations[] = [\n                    'context' => [\n                        'type' => 'post',\n                        'id' => $post->ID,\n                    ],\n                    'deprecated' => [\n                        'type' => 'shortcode',\n                        'content' => $matches[0],\n                    ],\n                    'instead' => $shortcodes_data[$shortcode],\n                ];\n            }\n        }\n\n        // hint: template tags don't need to be checked in episodes because they only work in templates\n    }\n\n    return $deprecations;\n}\n\nfunction podlove_get_deprecations()\n{\n    $deprecations = array_merge(\n        podlove_get_template_deprecations(),\n        podlove_get_episodes_deprecations()\n    );\n\n    return apply_filters('podlove_deprecations', $deprecations);\n}\n\nfunction podlove_get_deprecation_context($context)\n{\n    switch ($context['type']) {\n        case 'template':\n            return sprintf(\n                '<a href=\"%s\">%s</a>',\n                admin_url('admin.php?page=podlove_templates_settings_handle'),\n                sprintf('template \"%s\"', \\Podlove\\Model\\Template::find_by_id($context['id'])->title)\n            );\n\n            break;\n        case 'post':\n            return sprintf(\n                '<a href=\"%s\">%s</a>',\n                get_edit_post_link($context['id']),\n                sprintf('post \"%s\"', get_the_title($context['id']))\n            );\n\n            break;\n\n        default:\n            return '!!unknown context type '.$context['type'].'!!';\n\n            break;\n    }\n}\n\nfunction podlove_render_deprecations($deprecations)\n{\n    if (!count($deprecations)) {\n        return;\n    } ?>\n\t<div id=\"message\" class=\"error\">\n\t\t<p>\n\t\t\t<strong>You are using outdated shortcodes. Please fix as soon as possible.</strong>\n\t\t\t<ul>\n\t\t\t<?php foreach ($deprecations as $deprecation) { ?>\n\t\t\t\t<li>\n\t\t\t\t\t<?php\n                    echo sprintf(\n                        'Outdated %s %s in %s. Instead, use: %s',\n                        $deprecation['deprecated']['type'],\n                        '<code>'.$deprecation['deprecated']['content'].'</code>',\n                        podlove_get_deprecation_context($deprecation['context']),\n                        $deprecation['instead']\n                    ); ?>\n\t\t\t\t</li>\n\t\t\t<?php } ?>\n\t\t\t</ul>\n\t\t</p>\n\t</div>\n\t<?php\n}\n\nfunction podlove_get_deprecated_shortcodes()\n{\n    return [\n        '\\[podlove-episode-subtitle[^\\]]*]' => '<code>{{ episode.subtitle }}</code>',\n        '\\[podlove-episode-summary[^\\]]*]' => '<code>{{ episode.summary }}</code>',\n        '\\[podlove-episode-slug[^\\]]*]' => '<code>{{ episode.slug }}</code>',\n        '\\[podlove-episode-duration[^\\]]*]' => '<code>{{ episode.duration }}</code>',\n        '\\[podlove-episode-chapters[^\\]]*]' => '<code>{{ episode.chapters }}</code>',\n        '\\[podlove-episode\\s+field[^\\]]*]' => '<a href=\"http://docs.podlove.org/reference/template-tags/#episode\">episode template tag</a>',\n        '\\[podlove-podcast\\s+[^\\]]*]' => '<a href=\"http://docs.podlove.org/reference/template-tags/#podcast\">podcast template tag</a>',\n        '\\[podlove-show[^\\]]*]' => '—',\n        '\\[podlove-podcast-license[^\\]]*]' => '<code>{% include \\'@core/license.twig\\' with {\\'license\\': podcast.license} %}</code>',\n        '\\[podlove-episode-license[^\\]]*]' => '<code>{% include \\'@core/license.twig\\' with {\\'license\\': episode.license} %}</code>',\n        '\\[podlove-contributors[^\\]]*]' => '<code>[podlove-episode-contributor-list]</code>',\n        '\\[podlove-contributor-list[^\\]]*]' => '<code>[podlove-episode-contributor-list]</code>',\n        '\\[podlove-web-player[^\\]]*]' => '<code>[podlove-episode-web-player]</code> (or <code>{{ episode.player }}</code> in templates)',\n        '\\[podlove-subscribe-button[^\\]]*]' => '<code>[podlove-podcast-subscribe-button]</code> (or <code>{{ podcast.subscribeButton }}</code> in templates)',\n    ];\n}\n\nfunction podlove_get_deprecated_template_tags()\n{\n    return [\n        '\\{\\{\\s*contributor\\.publicemail\\s*\\}\\}' => 'the social module to manage and display the email',\n        '\\{\\{\\s*[^\\}]*license.html\\s*\\}\\}' => '<code>{% include \\'@core/license.twig\\' %}</code>',\n    ];\n}\n"
  },
  {
    "path": "includes/detect_duplicate_slugs.php",
    "content": "<?php\nuse Podlove\\Model\\Episode;\n\nadd_action('admin_head-post.php', 'podlove_check_for_duplicate_episode_slug');\n\nfunction podlove_check_for_duplicate_episode_slug()\n{\n    if (get_post_type() != 'podcast') {\n        return;\n    }\n\n    if (!$episode = Episode::find_one_by_property('post_id', get_the_ID())) {\n        return;\n    }\n\n    if (!$duplicate_id = podlove_get_duplicate_episode_id($episode)) {\n        return;\n    }\n\n    podlove_duplicate_episode_slug_notice($episode, $duplicate_id);\n}\n\nfunction podlove_get_duplicate_episode_id(Episode $current_episode)\n{\n    global $wpdb;\n\n    $sql = $wpdb->prepare(\n        '\n\t\tSELECT\n\t\t  p.ID\n\t\tFROM\n\t\t  `'.$wpdb->posts.'` p\n\t\tJOIN\n\t\t  `'.Episode::table_name().'` e ON e.`post_id` = p.`ID`\n\t\tWHERE\n\t\t  p.`post_status` IN (\\'publish\\', \\'private\\')\n\t\t  AND p.post_type = \"podcast\"\n\t\t  AND p.ID != %d\n\t\t  AND e.slug = %s\n\t\tLIMIT 0, 1',\n        $current_episode->post_id,\n        $current_episode->slug\n    );\n\n    return $wpdb->get_var($sql);\n}\n\nfunction podlove_duplicate_episode_slug_notice(Episode $episode, $duplicate_id)\n{\n    add_action('admin_notices', function () use ($episode, $duplicate_id) {\n        ?>\n\t\t<div class=\"error\">\n\t\t\t<p>\n\t\t\t\t<?php\n                echo sprintf(\n                    __('Watch out, an episode with the slug \"%s\" already exists! %s', 'podlove-podcasting-plugin-for-wordpress'),\n                    $episode->slug(),\n                    sprintf('<a href=\"%s\">%s</a>', get_edit_post_link($duplicate_id), get_the_title($duplicate_id))\n                ); ?>\n\t\t\t</p>\n\t\t</div>\n\t\t<?php\n    });\n}\n"
  },
  {
    "path": "includes/donation_banner.html.php",
    "content": "<style>\n    .px-4 {\n        padding-left: 1rem;\n        padding-right: 1rem;\n    }\n\n    .py-12 {\n        padding-top: 3rem;\n        padding-bottom: 3rem;\n    }\n\n    .mx-auto {\n        margin-left: auto;\n        margin-right: auto;\n    }\n\n    .h-56 {\n        height: 14rem;\n    }\n\n    @media (min-width: 640px) {\n        .sm\\:px-6 {\n            padding-left: 1.5rem;\n            padding-right: 1.5rem;\n        }\n\n        .sm\\:h-72 {\n            height: 18rem;\n        }\n    }\n\n    @media (min-width: 768px) {\n        .md\\:absolute {\n            position: absolute;\n        }\n\n        .md\\:left-0 {\n            left: 0px;\n        }\n\n        .md\\:h-full {\n            height: 100%;\n        }\n\n        .md\\:w-1\\/2 {\n            width: 50%;\n        }\n\n        .md\\:ml-auto {\n            margin-left: auto;\n        }\n\n        .md\\:pl-10 {\n            padding-left: 2.5rem;\n        }\n    }\n</style>\n\n<script>\n    jQuery(function($) {\n        $(\"#podlove_donation .dismiss a\").on(\"click\", function(e) {\n\n            var data = {\n                action: 'podlove-hide-donation-banner'\n            };\n\n            $.ajax({\n                url: ajaxurl,\n                data: data,\n                dataType: 'json'\n            });\n\n            $(\"#podlove_donation\").slideUp();\n\n            return false;\n        });\n    });\n</script>\n\n<div style=\"position:relative; margin-top: 60px; margin-right: 10px; box-sizing: border-box;\">\n<div id=\"podlove_donation\" class=\"relative bg-gray-800\" style=\"position: relative; background-color: rgba(31, 41, 55, 1); max-width: 1330px;\">\n    <div class=\"h-56 bg-indigo-600 sm:h-72 md:absolute md:left-0 md:h-full md:w-1/2\" style=\"background-color: rgba(37, 99, 235, 1);\">\n        <img class=\"w-full h-full object-cover\" style=\"width: 100%; height: 100%; object-fit: cover;\" src=\"<?php include 'donation_banner.img.src'; ?>\" alt=\"\">\n    </div>\n    <div class=\"relative max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8 lg:py-16\" style=\"position: relative; \tbox-sizing: border-box;\">\n        <div class=\"md:ml-auto md:w-1/2 md:pl-10\" style=\"box-sizing: border-box;\">\n            <h2 class=\"text-base font-semibold uppercase tracking-wider text-gray-300\" style=\"margin: 0; color: rgba(209, 213, 219, 1); letter-spacing: 0.05em; font-size: 1rem; line-height: 1.5rem; text-transform: uppercase;\">\n                <?php _e('Support Open Source Software', 'podlove-podcasting-plugin-for-wordpress'); ?>\n            </h2>\n            <p class=\"mt-2 text-white text-3xl font-extrabold tracking-tight sm:text-4xl\" style=\"margin-top: 0.5rem; margin-bottom: 0; color: white; font-size: 1.875rem; line-height: 2.25rem; font-weight: 900;\">\n                <?php _e('Dear Podcaster,', 'podlove-podcasting-plugin-for-wordpress'); ?>\n            </p>\n            <p class=\"mt-3 text-lg text-gray-300\" style=\"margin-top: 0.75rem; font-size: 1rem;\n  line-height: 1.5rem; color: rgba(209, 213, 219, 1);\t\">\n                <?php _e('Hi 👋 I\\'m Eric and I\\'m maintaining this plugin. If you want to support the work we do with Podlove,\n                please consider a donation. The more we collect, the more time we can spend on making podcasting better.\n                Thank you!', 'podlove-podcasting-plugin-for-wordpress'); ?>\n            </p>\n            <div class=\"mt-8\" style=\"margin-top: 2rem; display: inline-flex; align-items: center; justify-content: center; \">\n                <div class=\"inline-flex rounded-md shadow\" style=\"display: inline-flex; border-radius: 0.375rem; \">\n                    <a href=\"https://opencollective.com/podlove\" target=\"_blank\" class=\"inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-gray-900 bg-white hover:bg-gray-50\" style=\"display: inline-flex; align-items: center; justify-content: center; padding: 0.75rem 1.25rem; border-radius: 0.375rem; background: white; color: rgba(17, 24, 39, 1); font-size: 1rem; line-height: 1.5rem; \tfont-weight: 500; text-decoration: none;\">\n                        <?php _e('Donate to Podlove', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                        <svg class=\"-mr-1 ml-3 h-5 w-5 text-gray-400\" style=\"margin-right: -0.25rem; margin-left: 0.75rem; height: 1.25rem; width: 1.25rem; color: rgba(156, 163, 175, 1);\n  \" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                            <path d=\"M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z\" />\n                            <path d=\"M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z\" />\n                        </svg>\n                    </a>\n                </div>\n\n                <span class=\"dismiss\" style=\"color: white; padding-left: 1rem;\">\n                    <a href=\"#\" style=\"color: white; text-decoration: none\">\n                        <?php _e('or hide this banner', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                    </a>\n                </span>\n\n            </div>\n        </div>\n    </div>\n</div>\n</div>\n"
  },
  {
    "path": "includes/donation_banner.img.src",
    "content": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/2wCEABoaGhoaGiwaGiw/LCwsP\n1U/Pz8/VWxVVVVVVWyCbGxsbGxsgoKCgoKCgoKcnJycnJy2tra2tszMzMzMzMzMzMwBICEhNDA0W\nTAwWdWRd5HV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1\nf/CABEIBQAHgAMBIgACEQEDEQH/xAAaAAEBAQEBAQEAAAAAAAAAAAAAAQIDBAUG/9oACAEBAAAAA\nPz4oAKAAAggAVSgKCKAAAAAAAAAQIEIAIAAAKAAAAAAAAAAgooCgAAAggAVVCgAAAAAAAAAAEBBA\ngCAAAoCgAAAAQoCAAAilAoAAACAgClUUAAAAAAACgCABAQIICAAAoCgKAgAAAAAIAAUKAAAAICAF\nVRQAAAAAAKAAIAEBAggIAAKAoUAAAAAAgAIAFCgAAAAgQApVFAAAAAAKAAAIAQECEAgACgKKAAAA\nAAACACAUKAAAACBACqUUACKAAAFAAACAEBAhAIAAoFFCgAAgAAACACAUKAoAIAIIAKpSgEAlFAAA\noAACAEAgQQCAAKFFFlAAAAAgAAIAIUKAoAIAIIAKpSghAApQAAoAACAEAgQQCAAoFKUCgAgAACAA\nIAIooFAAAIIQAKqikIQBRVBCoKBQAIAICAhAQAAoKUUUAABAAAgAAgAKBQAACCEACqoEIhQopQQI\nKAKAABAIEEBAAChSiigAACAAAgACAAoFAAAQIQALSiEQUooCiIQFAWEEqCloICEBAACgpRRQKIAA\nIAAIAAIAoFAAEpAhAAtKgiVRVAASRCBYKpEIAKWqBBAQAAoUopQoCAACAAAgACAUCgAAEEEAC0EC\nqUKAEkkIAilhARQAWqpCAIAoAVRRQoCAAAIAACAAgUCgAAEEEAFUgLVWUUBCSSECWUEAAAAUtCAI\nKABSilAoAgAACAAAgAgUFAAAEEIAFWAtWlFAREkkILBUQKWqpAkiBVAAAABSlFCgAgAAAIAIogAh\nQUAAAQQQAUC21RVAIiTKIAEgNW61UQq24mELKhKgAAUAoooKACAAAACAAgAhQUAAAQQgAoKttKoK\nlJEkhILIRBbvepMxbVkkkqSW3TUNZzmACgAoUUKAACAAAAIAIAIUFAAAEEIAKKq6KpQAiSCQGUkF\n103MQtolkmrqSZEgjet4xICgKAUoCgAAAIAAAIAgCKCgKIAIIIAspVtpaAoCIhCEmZBdddYyW0Z1\nJlvcMqiSIga1MygoAKKCgAAAACAAAIBAIoKAogAggQFirVqqoAoIhBCZzIXXXWMltkS3ONbqWhJE\nRIBbcigKCgKAAAAACAAAIBAJQUCggAhAgAtW0qqIUoRAQTGcjfTecyrWZVYzvc1akmSiSRBBpKtl\nAoFAAogAAAAIAAIAglBQKCACECFgWrVKqwFFESxYic8wvTrMS22Zl3qYzNt1EzLVRIkQEIrVUBQC\ngAAIAAACAACAIAKCgIAgIEsoVVpSoFCqQCGeeYb6dOcW3WebruZZsu2WYtCJIIgCIurSgoAAAAAg\nAAEAAIAgAoKAgCAgIoqqVQlApQiiM4xF32c1t1nOb17cNRmb1lmKCIgEiJQiW6tKAoAAgAACAAIA\nAgAgBQoCAQCAAqqVSBQFWCyxOeC77Zzatzzmu+ZtnGtxnNoiEC1YTMkhYDeqKAAAAIAAEAAIACAC\nAoKAgEAgAKtKCKAFCgnPMb6dOarWMTe5duc6WTFtlkRCBVAkmZBbG90KAAgAACACAAIACACBZSgC\nAICARaLSiBSApQVnOFvXeJV0582rrdc265zVEkkACgBJmRaTXS0AAAAgAIAQACAAQAgWUoAgEAgE\nqlUpAEsoKKGMLenTmq1jm1N9TF0YmhJlAAKBAGItib6WgAIAACACAgAIAAgCBZSgIAgCACqosAgA\npRTOJdb3gXcxzmnTpc43qTGrCZkAQooCRS3OJbI300oCAgAAAgICACAAIEqBZSgIAgCAKLQJUIAV\nQVMS66dOcG2OZe2tZ57qY1UZhIABRREirZMRYuutoAQAgAAIICACAAIEqCgoICUgCAULRYEgAUoG\nc3WuuENsczd7XPLpWZokhEgQu7c5i0EmFWxJUrXWqgCAAIAAggQAgACAIKCggCACAoLRYIgihQFz\nGtdJlLq55x0nXeeW9STQzm2EhBNdOkxzi0CYzU0qLFm+tBAEAACACEEAIAAgCCgFgJSAIFCxVKgi\nBCrYAy1elzldaxzjep3nLWploYlUSIgN7ZxGlCZxKWWxbGe26AgIAACAEIECAACAIKAABACCikqi\nwJAIqiKZXW94ya3nHO72rOtTNtTEW0SJEBWplqgTOJSW2NzOdd9AEAIAACAhBBAAAgCBQABAAgpQ\nKARFgAqLcy6vbGC9JzxW989TWmbomYLaSRIQBLbaEznMspS7nOb70AgAgAAQIQQIAAIAgUAAgAQK\nqwUAQlhKALcNut5S3d54a1XPV1c3VkiBasZSIEEW6oXOM5UKXecOvUAIACAAQIIIIAAIAgKACAAI\nUtIpSCAgBYW5mrrrzxW9YxnWOnTMutZnSyQiLasSSQIQLdKGeeZQo3rnL20ACAAgAICEIBAAIAzQ\nKAIAAhVoKBCAkoFSri6ds5lvTPOMO1yumem2cgirRJJCCCLbSjGMNFmsrthrrQAgAAgCAhAgIACA\nMqAoIAAgVaFLLCICEUUXMuum+ebda5Sc3TqwtnTWsSKQW0TMRBCA1RSc850qy41rWY6bAAgAAgCC\nCCAQAIAyUAqAACBVotlEICIJSjU53WusxNXeM4xOnWM3Wem+nPMKEtWkzERBCDVCmM5mlsSdDNda\nACAACAEIECAgAgDJQAAACCrVCgkAQgVS85rXTWM3epznJfRMpuXp1zjIUFtpMxEhBAaKExMXVrNz\n0kJ02AAIAAgCBEBACBAJcUoAAACCrVDSCQAIlUVya6dJza6Oc5x13iy2dernmBQW21MxEiCEFooz\nnM06TMOmZLelAAIAAgBAiAgBAIDCigAAAhS2hagkAVELUtxjWulzl11nnObXq4w3nffXLEAoW20m\nUiRAIlUUmcxvVnOtyZ3N6AACAAIBBBAQAgIDCigAAAhVtBdXAkAoRVReTpek5t9c83KX0TFzqu/T\nHPICiW6tGYkkAJJVFGIm3S8s26zna6AACAAIBAhAQAgIDBQoAAAhVqpbrTCIJRSFEs5dm9Yxem8Y\ncb09Hnstx37zlgBQlt1SZRJIpUkFKWZka1vWOTW84dcaoAACACAIIQCACAgwUKAAAIVasW66ZwgQ\nKWALynpx2zymu0xOeb6+WE1L6bjEihQlttXMiTMLaJIFLWcxemuvLk3qYx1zqgAAIAIAghAIAIBD\nBQCgAACrUN71zhAiikUS8dejPo8/O9OuMTGde7wals69sYyBVCNW1M2SYkLq0iAFszma6Xvjlne5\njm3aLAACACAgIIAgBAIwUAKAAAtpDVAiwFWFInLp11vhm9tcmJPVnzmjuxkBVBF0BnMyi21SQBSY\nzenTpeXPXfGeedtVFgACACAgIIAQAQEwoAKAAAtqJdKAuSigExjtrd4zfackzr6Pzs51qdOnOQBV\nBFUtmcxJVVRkClzjLp2u+fN2Tnz3aoBABAIAgQIACAIEwUAKAABVWDSgVkpSxRnlPTO3LDr0xiOf\np9Pzo1b3zjIBVAACRBVVJAKXOczfbW885d3HPWooACAIIAgQIACAQEwUCgAAClCXSqCQpaAZ4a9L\nt583trnnLn9DHkl6Xp3885gKolAKREClJIBSZzNdumpjM3158p1yoAAgEEAgQCAAgEEwUCgAAUCk\nW3QJcyqWyyhz5de16cJrtnncJ9Pwcs9N69GufLkAqiKAoRAUIgBGcy9vQY5r2582s2gACAQQEBAI\nAAgITFAoAABQFRbq2BIKqoUnPn6p01xa64xJOvu+bmdNdPVnPmwAVSBQoEACACGcS9e+tY5x254v\nTjaAAICBAgEAgACAg50CgAAUWIoNatQkCqqBZxnt5dJmdOnPGWfV28OL1vb0c8eUAUAFKAgAgAsY\nxHXtvrjjnW8yd+OaAAICBAgCAgAAgQwCgABZZSkRUXd1qZJCWqWAnG+zl25p03yc2fdz8+Nddeje\nOHEAKAFCgQAhSCpnGXT1umOeLqR1xzWiAAgQIEAgIAACBMCgAAoKCQF3rW5zJCWqWAnHXpz25J1v\nHXOZ9/mxjprp6s582AEoChQLAoiwCCpnEm/T03nnz1RvPJaIAEBAgQEAgAAEEwKAAFCgJAXet7nK\nERVpYDPLfXfTkdM53xZ78857a798c/LAsIsCloAKAQAQTnGuvTq58201XKWgIBAIECCAgAAAQ50A\nABQopJAXW97nORELaWCJy306dOU1vnrPNevLL059e+fLzoCBEpVtCwCgQsAhOZreuuueJvWbvPJa\nAgCAgQIICAAABDACggKFKEkA3vepiSElW0ESct9O2uc259/NV3jnr1Y9dxw4iIQiQturVCAoskUA\nLnGV663rGJveHblx1aAICAgIEEAIAAAjAoAgoKUqJIF1vVSYCS20lSMZvXvcTpjn6PLuaTl06b9T\nlwwSIRIyLbrVqiAoqZltgGk55jv0usYmt5vXjw3aAICAgIEEAAgAAjCgAAKKKSQC6urmQiKtsRZM\nzXTvmTWeffz6enhnn1vT055c5EkkEmZC61rWqUQChnNtsC2zniO/TXTnzl1NduPm3oAIAgQIEEAA\ngAAOagAAKUoRIUXWrzRUBakLOetde/OXXPHTlXq82cdWvRyzEkkkEmYLvWrbSwAoTMa0QLnEO2+n\nXHHN3nfXl57oAIBAQIEEAAgAAOagCUCiigklWLdamCIoUkhrnrXo7Y3Jxzrmz7vNjO2u3OEkzJAi\nDV1bSqAAJmXVpCTHSTpvt0zw563nfbl57QAQCAgQIQACAAA5qAAClFFRIoWgkQpUzJZvn0e5bxjl\npz37PHjPS66ZSSTOS2mYi20irVKAEhSqZ5revTXbHDGtZ13xwlAAgICAggCACAADmFAAooookAVQ\nCSFJnNb59cdenZjONb5Y7enxM9HfWMSSYlttSSSSqZirbq1SKIALE5ta31vTnyl1nXfHEAAgIBAg\nQAgCAADmFAChRRQiShVUgqZgZkusdufvwlvDtOW+l4856M+lOWZmS20jMzJCsyFautaqoAKJUmcu\nm2t654VnXbHNQAICAQIEAIAgABMCgCgooosSShaUC6zmMxIazdfU4YJz9M83T08OLfTPpy4ZzC21\nISZzEkgFut6tABQCYy67TrcYGW8zVACAIAgQIAgQAAIwKAoKFCixIlKqgq7ywmJBrLf0+GLrHDvf\nP6vR4+fPvrXp5zzYIurZJdVMkzjMCLd3VoAKAmM3po6sYtmYzelAIAIAgQIAgQAAIwKBQoFCipEl\nKqgHZgt88kupNe3MdMeb058/r9Phzy9F69MPLiZXWtWSb3qsRM4zlEhbrVtAAKJjOtarrMZbkzia\n6UCAAgICAgCCAAAjAoKFBQooiSKqlBJr0Zm7eXDC25vo3mdJ5fZnzdPZ5McvRrrvlryc5Gt70Na1\npMSTOM5SQlaurqgAFSYmtat6Zzl0zM4muiiABACAgIAggAAIwqUUKBRRQSMqLVCszp11brPm5ZOm\nNd+vK2cvRjlPd5+XP059PPGvJjLW96t1bamMRMZkSIFuta0oACyc5vWrrec5upMZmt0AAgAgIBAI\nBAAEMKFBQUFFBEkoqqW1jXfVrnw4w3m+v0+Xl0cevThj28s8u7v5pPNhb01vVpnMmJJIqIhC61rW\nlUALM4NdL0uc51ZM8502ACFgAgIAIBAgAEMgoKBQooCRJRapql301ZnljzyrNdfTz49OvC+jz8fo\ncud268E8vKXe9bSM5kkSQUWJBrWtWtWgCzGa10vWZwtyxhvVAEAAICACAEEABDIKCgUKKAiSKLaX\ncu97058XPjFsvf18ppxz6efm904OuunJz83FbvbGZMyIBKVREt1bWtatKhWcTbW71zjKk54vS0IA\nAAgIACAIAgEMhQUChQUCJALRd3Wt7uOGbz5RbHr9vm7b4ebn6seb3b8rprr52eHFbrU5xMyAQKqo\nLbat3rSiFZ53o1rW84yLnGNbtgAAAgCABAAgIEDIUFAoUFAiQUKl3rW9WcMnPMlser28e2uXm8/q\neb29PJq63xk5cS2ySTMgIQq0JdVau9W0SLZjGurbesYlS4xneqAAACACAAgCAgQMhQUCgFFBEhSU\nXW9auecTMkW4dvdy76x589uPP09/MN5mOXCFqyZkkWCBboFq01dWrEzNamMb6Xd2xhUuOc6WgAAA\nIAIAIAQQEDIUFAKBRQRIUil1q3OSIhd8Z09U6Uk546erlhNSZx50SjMkyURC26tBozbbaWTGd7c8\n7661d5xGpGMN6AAAAIACACAQQEEgUCgChQCxEKRVtQRSS3pxxr0dc7aZ46z7MYmLZM8cZkJEgUQi\n3WtUNpjK0pTE3qYm+u7u5ygmM3dAAAAIACACAggIIgUCgBRQARCgtkLZFpF3ni7+jlq9bjgnsc2K\nyx5MRCAoAS61rVqa0xzwtk0l2xNazJ067vSZzZDOZrVAAAAIACACAggIEgKAoAoKARCqTSZXURaS\nXd886ejMvbpjix6+nnkuudx4eYCirAF1rWhV1OfOVJdJrUzLZHTru7mBDGW9AAAACAAgAgIIECIC\ngCygWUKgEKtFzm2wjVSNa4Z131ia9GfOerr58W653Pm4oAKQF1u3RCreeCZbsauYJL1661IXMszm\n7oAAAAgAIAICCBAiAoAKAoACCrSpm0ItIXni9evPLpM2d/Ry8+7c2Yu8c5nLMghDXS3VBEWmOc1q\nFkVmNdt6sipGZNaoAAAAgACAIEEAgiAoACgFAEBbaJNVCFqGc4b75zKzu3vrzbXnpnr0kTXQxx58\n8Swt3uRAQl6XnyqJF1ZiOnbVQIzI3oAAAAIAAgCBBAIIgKAAKBQCAW21CghQkziXvrnLc63z9OuQ\nxU66auddLU5c+M2nHGtbSQEEu+ueGEkutzOF69NWIsTMmt0AAAAIAAgEBBAQIgKAAKBQCALbaS0I\nUQzjDp3xlpLnt1xiySr3WZxenWc+WLcScWrWrmBBF13vm45L0ucR06a0QGcxvVAAAACAAIBAQQEC\nIKAAKAUCAC22hQSgJjEei4asm89nLUZrDVzz5wkSp2uVq6sghAa9Ty+ea3qYk103oCGIu9AAAgKC\nAAIBAggECIKAAoAUCAhVttFAKQTjl17Yasyna4GKcuczJAg36+JVtKskgFvpnm462xld70llDOZd\n6oAAICggACAQIIBAMlCgAAFCAQLVtpQCgOXKb9CXXPOp11iWQTUznOcZkhe3bkqhVqSAF9GeBnC6\n3pLFpMRrdAAAAAAAEBAgQCAyUUAogBQgICratoBQCcMXt1yuWNurAzQkmcSSTL03CrcqUEAL2zyz\nnN1rdgpWMm9UAAAAAAAQECBAIDKlALKAgAAAVbaqgAFcuM33uYc9XrrMM2x0zMYzImcvTMq1JYoA\nAb6cuOV3uotslmJbvQAAAAAAAgEBAgCDNKAoACAAApVWrVIAoZ4ZnbtiSsbdbiySno1nlzziSZxf\nTnKioKAItTq4c2925NXWDGa6aoAAAAAACAEBAgAjNKUAKgAAAKUq22iIFA5cs677xlvOL03JZjQ1\nc5ZSc8X0Z52iwBQIC9uXG70ytqLnObvpQAAAAAAIAEAgCASFUAoACAKBSlW21SSQVRM8cTfoYi8u\nl3rNklSKImczvjmosigUQIdbxaszbdTNZw101QACAAAgCgAQAgEAypQKAAACgVVVbbQSSSlCc+OX\nX0YyqTXRjUxokoSRNXioIsoKIIa684kXVYtzmXpqqAgQgAIQClUoAgAggMqoKABQAKClWrbVBJJB\nQTjyzrt2xGspvWYuaSiTI1vzKJYKBQiF65zMtXTKuZvdAEQiIQoIQBVW1VAgAIIMqoBQoFACgpVt\nWqoiSSCgzwxnffpiWM6u8EpGiTMp38kohC0KFZI6MZXVSLEu7QJEiIhAlAgAVbbqqAIAQQypQCgV\nVABQVVq2lCJJEKCY44nTvvEJNbYtxolpJIejjwUgFClLJI3rlLqpEtk3pQkkkiBAJZQAAF1rVUAE\nBBDKlAChbSoKCgWrVoAkiQUE58cunfWEknTUzctGdWEkdOnjQLCiktAmdb5LqSxKmtVSSZiIgCBQ\nACUC7uqoAIEEMlAKCltqoAqkFq1SgRIkCrCcuOXXvrMkz0azGdW5lBJfT5eQAqhKpFmdawqBI1qq\nJM5hAACkCAAC3WtWgAgQQyKACi220QKqyxFq0oCEJApSZ5cs3fbpnLF1bkx0XCoRO3Tx5VAqgUQZ\nuskAACySAACiEpCoBCl1vYoIEsCMUAClltttqQKqkRVoUIBBFFDPHllvt0mXPTaXOelZzURN+jhx\nlEKosVBJNJlC2giBFQABQgVIqAAF1otqgQCMKACirbqqiFVaJJVUAAEAUGeXHLXTrrOec6XeWXSz\nMSIde/kwRYVRSQRFzAtosIQhUABQQLARUBYFgturpYIDBQAUW3VtIiqaqySFUAAAhQpJy44XXbpM\n8p1tjLoZlxGZvv18eSLFqaixEgqRKVQECIWVAFEAWAlgARYhS63qiA50KACtXVtICrbamZKUAAAA\nVSTnwxGunXWefLXVNRtGLmRN9+viyipRQSRQjLUVQCBEEtgWUgCUgBYBFggLvpQDmUAKFutW0gKt\ntpmQoAAABVBJnjyyu+nS8uN74u5nciGRrv28OEoAJKBEAVQglIRDWaCiAAAgVBIoA11oDmUCKFW6\n1q2IFLbaTMUAAUACgInLjjN103ucnPrdSakM25zrfbv4ucRQLmCrJYhKWhCLKiJK3hY2yJRagAyq\nCVmWgEvaijmUCCi3Wrq2IFLWlkiUAAoAKABMcuOV1vWpzz2WZtlSbuM73348JJVCZCiCCVaSkJZU\nSZ1dYauLvWeZUKACAlMxaAS9aoMKAhZS3WtW2IFKtEAAFlACgUBGccuWVt1ZnptcTdkrbEXs8+IU\nGRRIglKqAqAmZnepEib3vTGMhYCwASSLaAS9KoMqJQFLdauqiBRSiKABQoAKUIITny55C6jpuzLV\nlJMN66OfKCKyBIAC2AAkzM63ZhvETVuunTrOXOZiAoiSELaAkuiwNlAClt1dWogUKAoAoKAFilAg\nJM8+fOQurbvUzaSyRbrUznKSds4iCAlS0AEkkjemJLqc4t0tu6WUmYmYQIW1KJEWgOqgBS23V0qI\nFCgKAKFAAKUAASZ5c+chrUatWsyXd551oaznLe5mYhAAUqxmSJbZIi6coVdWhCBAEKqiIIWgOqgo\nUturbSIFBQCgUCgAKUAAqGc5xjOYBrVtkt54yVLtLvet61mZmYqBImWJIWySJFtvKFFt0EIQIVVE\niBKWgOpRSirbq2iIFBQAoUCgAKUCgAJEzMxDOZm3RJIy1u4jNuunXrq6LamMYnPGZGVZkiSQW6mL\nq3RQiK0kJAESIiCloDqUtFFturaiRKKBQAooKAApQKACIzJlFVSROXNI11WZykzGt9enTV1rTOJN\nTKYnPGsYymWVGtbqoELAEAEUgiJgVaA6lVaUq3VtsSRKCgoAooKAAUUKBJMySTOYFVbq7Y8+Jl06\na055iTOULrW973q61dYxMYxjMxJFkq63oKCWWBBLALEpAJMwpQOqqtqhbdW1EkgoKAoKKAoACgUJ\nM4mZIkxFtILbvpPLk69N7xyzETEIEq6t1bJM5zAVczW96IKoEQJYgKEBCBMrYA6qttopbbpUSZAo\nUCgoqUACgKCyYxnMgSTK0BA1cY12vTu83KCZyiKkAWxWYqlSdN2kpVIEQFkQKCFhIBIpA61VttFL\nq2rJMwAoKAooBQBQWKHLniRQkRRCCBM76b3u8+YRJJEzAQtNM5lqrF3apKpUAgKkiBQQIgCQA7Ft\ntUVWrbUkzAAUCpVFAUIpQsKTjyyKGUqiRAhLl23YkgVMyLUxBC3Wtc8ZaLDdq0SqWCUgAkQKQCEA\nSAOxbbaFW221JmSAoKAoooKCKoUBy80UokS0IkEDetAzBQygtYyKu9OWMrVmda0VQFWASUBEQlok\noRAJZAO5baoVbbbUmZICgoCiigoAosoJw4FUkRVJZCEN7supGYlUpEVpObW+vPLPPMl1ZJd2igCk\nQKJQiEiqgIIAkA7lttCltuqSZkgKAoUUKCgCgAcfOKJBVgIkXdRdJSZlKtQLV54t0znEjWkzLvRQ\nAURCWkUIgyCkACIsA7LVtCltuqSZkgKAoUUKBRKCgBjhzURFoCQjWpldW2SQsFoDQ54aMSNakkzd\n7UABSECoUhBMzQVAAiLkK7VWlClt1aSZmQAoKFFFJQAKApOOMtbznOVoCZLpIrVXMiiqogtZ5Lcy\na3c5mczXTVFQACWKEAQhMrQpJQIGS23oW2gpbdWkmcxKAoKFCqIUgoFCjPPDfomunnzvTnjnvOM1\nmolWrJKsoqhBZjmVd3MzI1VtKIAIJbYQEIhMtUVZASoEtqtltoFW6tpJnMhQKCgoqkACgFCpJiNa\nvO7t5ZyGcFS21JCWxVAlpMc4a0ZhSNXSqSAICWiBCEJmXVoEBAJVtXRbaBVuraSZzIUCgKFLQgAK\nBZQJVmZkQWscgFtkgoKSBVZ55i2JFVE3vVqxIAhYUQgRLJJJrVoSwEIsLat0W2gVbq2ozMyFAKCh\nVpYgAFBQAW1lSIZ80BLbZEUFEQUzjOYAKU1vVWyIAAECBESSRrVUgAgQXVXStWwFW6tqMzMgoBQo\nVapIJQFBQAVbJaiRw5ALaRJQChCZznMhVWqBrVNREKCAQARJJlLvQAAhCLdVdLbbALbq2wzmSAoF\nAotqogCUKFAAFASeWANNQkAJVEkzmZQVbVQhdaoIAsAQARJMwu6AAgRFtttq21KC26tsM5ygFBQK\nLVIAAKCgCFADPmzQWqhAAqGcyZk315QtoIQurVglAAIAQzJJdWgEAIhq3VUurZCi261YSZzAFBQK\nLRYhQSgoAAoAM+fChShAAlkkzJku4WgqRErdUIoSgAgIkkLaIsAIQ1dW0W22QKtutWEmcwBQUCii\noFAAUAQkBbSs8uJQKqUgRZJJMwXS20LZJlJDeigAAAgISQpSCWAQt1q0ouqkCrbrVhJnMAKBQKoA\nAoAJIkSSQFW61pjigoVQkoSSZmRa01aVL0c5nKRdWgAAAEBEQAAgRbdW1QLqyAq3WrYSZzACgUCl\nigAUEzmZkEohAW73mOc0ALd9LFznOZmYkhVq22tVl1vNnOUW1QIAABARCJQJUBbbbaANWyAq3WtI\nSZzACgUCgoAAZxjMhQqwRKW9uvPjMxrW92W6vXpWeeMY54znMBVqrVpG61jEkqqAICWAAEgkUigV\na0tAIrWkgLWtXSEmcwAoFAoKAAmMZzAKLSES0vfpz4SMy9N61dau+lTPPly5ZkgKpVKVBbMxVoAI\nCAACIJBRS1VqgAitauYFVrV0lZmcwAKCgUKACY54iChS0SAp13jnkmJrW9b1db3qzPPjzxmCFKFK\nCLCS1aIKgIAAEQRFWqWqpQgCF1pIFW3WqGZnMACgoFCgBnljMAoqrSSAtXUxFmZbrWqt3rUmMc8w\niFAUogRYUtQAEAAIEEKtqqKoCAIXepkFW3WqGc5kACgoCgoVMcsZAotuiyIkFFrJreJqraq1JnOZ\nIIFgUVAkUUtQAIAAIEELbapSgEAgmt6mQVbdaoZzmQAKCgKCimePKAFVq6LMokoChobttKmYzmSE\nACFFQSRaUoACAAgCCJbbbRSgEAgzvesyULbdaokznIAChQKBS2Y8+ABVa1qRZEiUBUW61rS0TOMy\nSCAAigERevbrrn5+SgAgAEAQRFtttKoAgEDO+lzlQtt3aJM5yigChQKBSnPz4KBVt3qZESQoCVdb\n1rWpUZxzzCCAAhQETXbv1snDz5KIqAAEAhCFtttpQAgECb6M5KLbrWkWZmcoKAoKAoKpjz4gKW27\nVkJJCggu97uraGefHMAgAIChF1369LM8fPkAAACCAhBdW21SiBAICb6TMKLbrWkWZzmQKAoKAoKp\nnjzygpbbqklJmKBYN73bbVVOfLlEAAIKAhdduu6zx44AAACBAQkW3VttKCBAIBvpnMFLbrWkWTGZ\nAoBQoCgpUxyyiIVbq0hJAKFutatpVpM8+WYhBRShCIhrXTp0rGOWEAACAgSwiVbq3SqAggCAdNTM\nFLbrWkWZzmQKAUKAoKKmMS61ZWcru1MyZkQFttt1VBVM455VVLrpZjOZJEkiFutb3q3OMZyDIIAE\nqEEFturaoAggCAvS5yFLbrWkqZzmQFAKCgKCqSSW61al6brPPOM4kyFttqqoCiZzmLdW27qZxjMz\nJlEtautXV0kzjMUVCmZEIIQlVbq2qAQCAgDXRnIUtutaSpnOZAUAoKAoFUBbaa1tZzxnGYilVVLK\nAUTKQttuqmcYzmSQtW6tttVMzMJVLrcmM5mYRCUWrbaoAQCAgE31mICrda1UqZzmQCgKAoCgpSrS\nl1us4znMCKUUJQAERQuiZxmSAq21VKskiAW6smcTMiCFVVqqACAgEATp0mM0VV1rVSpnOcgKAoAo\nLKClVVpbrTOMyQAqKIUJQBKBbGcyQCqpQoSAFaMzOZEQFKpRYAICAQCOu84ilVda1UqZxMgKAKAo\nAoKVVpbVmYkIKsBBQIKAUtqZzIgoKAolgBbbJmZkIAKFAgCCAIBDtrOIqlXWtVKmcTICgBQKAKAK\ntFtpISAtkCBRAAoUuhM5QAAAsqAKtJJmICWWUACAIIAgIO7OYqi3WtWKznOYAKAKAUAKBRS22yWS\nBbcyKgAAAoq2mZIEAAAAClJJCAJYACAICAgCDuzgpS3W9IrOc5gAoAoBQAoBQW6BkF0zIsAEUABS\n2oygQAAAABSJAgCAAgCAgIAg9ExkVS3etIsmc5gACgoAKACgULaWQi6rOSwAlAAAtEQIAAAAgFEQ\nQAgAgCAgCAgPRnOQtLd61CyZzmAlAoFACgAoCiqWRYurMwCUlgACpYoJUACAChAAIEAIACAICAIE\nA9GMwKq61uosmMyQKAKAoAoAKApRYC6uZAlCAAAAAACAAACAAgCACACBAEAgDvnMClutbqLJjMkA\noCgKAKAAoFKAlumYAioAAAAACAAABFgCACAAgAggBAIA//EABgBAQEBAQEAAAAAAAAAAAAAAAABA\ngME/9oACAECEAAAAN0AAAAoAAACgIEAgACgAAABCgAAAFAAoSpQAICBAAUAAAAASgFRSAilAAUAA\nAgIEACgoCUgAAAKAIEoUAAAoAEAggAsooAAEAABQBAAUQBQAAABAgAoUAAAIAAUAIIKVAUBCUBAU\nWCABQooIABKgAKACCVKBSgSIAQAVQgAoUUCAAAQAoAIIKFpQRJAItpZWUKCFAKKASoAAIAoAQAFq\nlCREEjVqURclLIAChQABAAEBQAQALVCiIJlNaSiGiJEUAoFAAAlQAIFACABaUFIJMy7SpUaEEhBa\nCgAAACAEBQAQAqligCYmtxY0ipKiCCLaAWUQAAEACKACACqAKEw3Rm6SoEAJIW2gABAAAgCKACAC\nqgFBlqjLWoSpIChElXSggABAAgCKACAFARQrLVlZa1LCJAKIWQuwAIAIAQAigCABVhAoJqy3DWs0\nQiF1M0SKhrVQABAQCACUAIAoWEChNWNYaoXIQjSKTIss3oQABAgIAIoBAFAICypd5azLZpUiiRAt\nJkKnShAAQICACKAgCikCCiasrM2W3IUkgi2mIWal2CAAgQEAIKEAKoIIKS7zbnN1Ld5lSqyhBaTC\nitUCABAgEAgLAAtFiAlE2m84blu5lRaykEUrMaixsAQAgQCAgAALSoRUUzrWdM56SXozFFqJBAqZ\nupLLaAQAQQBAgAAVVsgBYaZ3MXWbemIUWpElIFZbuKloAgBAgECAABbGrEsKGdbxdZz05ruQUlpJ\nlaQVlrpz1M2gBAECAIM0AAWxaEoWY6axuSdOV0gWVKJCiFTO95awoAICAgAiAAFBaIqiY63HTOd5\nzvpnAUAQVEVM66zOsygAgQAgCIABQF1CKsXN0axO3FvrzwCwoAQEzeuuepcgAQIAQCIACgi61JCr\nCXRczryvXXGACgBKlTF7a5lkqAECACAMgChYhvTMKsM3pLJNY6ds8QgWgABMt9MLcyiAQIACAyoA\nKSLdswUSXpLM1OnXnzQiLbQBKDM6aytwoQCBABAIAlFEF0yCwzrrlJvM10xlJEi60oBFDM6bmbrE\noQCBABAJKAKWJSkBJW+jOZvC6jMlRLooUSWkjpuYu8QEAQQACDNAFKQKCSyVraXO+d3cSKSQRboq\nFizOtbzm6kAgECACWDKgUUQKGpJJZ2rOs75b255WpZJIS60ApM7bzEWiAECAIAzQKFCSqF0vPJ00\nzrHTlvpOOW6ukmcyFugWWSaus2SaqAEsBAIAgKCgkpSze7z5Lrpmax0561njNbtsmZIgbtASa1c1\nhoACAQBAIFBQEFW3dzjma65rHXGrnhNakkhKEt1bbFmbrTNmWgAQAQBAgUFAQK1rTnMGvReeHXnp\nOMtzJAKKt1pBnd1MmNUABABAQIFAoEBbplIO25nPTK555lSQQ1RVtM1npdZRjVABLAAgQICgUCBQ\nC463TGtZTniRCiXVqzFVc6jppIk0AAQAIECAUCiBSUlLjddOd3lOfIFSzV0LnJVia6VDLQABAAgQ\nQAoABalRSZu82a6c6mpDOcl1RC4Cyb2JJqgACAECAgCgAFosBmbDplG6bnPLOdVCLrOJbl00hm6A\nACAECAgKAAC0oExdyrZEzMoaq0QOmOe8zegyugABLAECAgoAACrQDGepG8hM5knWKLAbznOtCzN1\nQEKEAEBAigAACrQKnLW5G0LcZkz0gUA1zaKZaoAKgAEBAihQgCiraiKYx0JdIEkzvICkNSWWpnWg\nAAABACAoABRVtEgTnOsstksqRcAFQaktiNUEIAhSqEASUsoKBRVqoyDPPW0UBJrEAoRrKpZdESIE\nqAVq0EAzQKWgCrQRAYxraKllQvMFAms0hoZhAFgBdWggMqAttgpFoBBUxjW2bYsI3zgUCWRQgEFI\nABdUASAUtthVQUCBRzw6XFpYku+QUIlkqiAIUllQAC20TNAW6qFtSKAAVM8mt5miyJrfKCoCBRAg\nKgABAuqM0BbqoVagABQZ55bubUC75yKgQFICLFQUICAatM0BdWoVQAKABnnlV0Fk3MCBACoSUjVq\nSAJFBaKAtuiFBQKAKQTGItqosqRZIALEiyWSroIhChCjVCrbSFAKKAKARnMQpmKNb2izMzJCSFSq\nqVCKRAo1VKttiCgKKAKBEkgqznlvWZJNa1vQmcZzENWgQAEJFDVqqtqQCgoBQBnMiRYLrnd9OeUk\nlirZIVNaFCBACCA2totqSUBQoCgYxKQIIdKyMkhLbmUtooIWIAgQNraLqySLKCigKCcSwCE1aZKR\nWNWMxZqgVACAECDdqltskhQKKAUJxACKWpLKFZXOaTVAqACIVCwg6LRbbJIKBSpQBZnOrJZJAVCl\nhZkWSaqoAAhJVQCVdLRbbJkKAqwFAIJZEyUhQLMSkpbSAAgkWhAltq0W2yZAoLSAoAoSchbAAmZB\nRq0gAQSS2iBFrVoNWyZAoLUAUAAxgqwATMCiNUAEBMrQCGqtqVbSZAUKACggUzzFAQzI0pYkaoAE\nEigENWlspbSZAUFACRIQNVgqXRJJIW1VTMWgAIIBBbaGkpbpMwBQKBM5gUitb5Rd272xz55hVKKk\noARYEJQq0BpKXVjMAUCgxhKKIW3LLWrrVzzwRQpLCgCAEFVVCBpFXVTMAKBRzzFLakShTS2JmIAq\nIpQIqAQtUoIG8i3VTMlAoCpygq6REoNa0MZggAL13jkAEAiW2qBAbyLdVMwAoCnLIrVREoXd0Mcx\nAAN9bjlAAEEW20BAusi3VTMlAUCmcgtRILbbSs8waSIg3uznCACCLdUAgNILdVM5UBQFEujdznEi\nrSgZlXVmMyRbdWzMC2ZkILVoAgNILrRM5KAoBVU1ZnICgCKWZyKqhAtzmQFUWAQDczVa1LM5FAUA\nVVVJFCFgULZlBQAKkkAoQCATpmUutGcwUAUAUtIlsgACqSAAAEQAQAgE6ZlLrSTMBQCgBSkWyAAB\nUAgVABABAEAjpgq7qTMAoBQAUFuQAAAQAAEAJYBAIP/xAAWAQEBAQAAAAAAAAAAAAAAAAAAAQL/2\ngAIAQMQAAAAIAABRAAAAAAAAoAAQACxYFAIAAAKgAAAAAAAFAAgAAAAsUgAAAAAAAAAAAKABAAAA\nACoAABYqAAAAAAAAoAQAAAAAAAABZRAAAAAAABQAQAAAAAAAAAKgAAAAAAAFAIAAAAABYAAAVAAA\nCkAUSkBZQBAAAAAAAAAAAAABQgAAAoQAAAAAAAAAAWVAAAAAAAAWAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAsAAAAAAAAAAAAAAAFgAAAACUAAAsAAAAAAAAAAAAAAAB\nBQAAAAAAAAAAAAAAAAAAJZQAAABKAAAAAAAAAAAAAASiUAAACAUAAAAAAAAAAAAAJUoAAABAsoAA\nAAAAAAAAAAAQpKACKAQCgAAAAAAAAAAAACCoKAJQAgWFAAAAAAAAAAAABAAUAAAQAKAAAAAAAAAA\nBKCAAKAAAQALFBKAAAAAAAAASggABQAAEABQAAALAAACUAAJQQpABQAEWKgAKAAACwAAAAAEqVKR\nSAChKAJYqAAUAAAAAAAAAEsVKiggChKAIKgACgAAAAAACUAIFiwUgAVAoCAAAUAAAAAAAlAAgWVJ\naEVAACpSAAAUAAAAAAAigAgWWJaAIAACoAAAoAAAAAACKACBZZFoAgAAUgAACgAAAAAAIoAIKlkp\nQBABYCoAAAUAAAAAAAAAgsqCgsEAFgKgAAFAAAAAAAAACCyoKAAgFQKkoAAFAAAAAAAAAILKgpFA\nCABUAAACgAAAAAABFBALBULFABFgVALAAFAAAAAAABBRALCiAKARUFQFQAAoAAAAAAAIKQLLCiBQ\nsqAiyiBUAAUAAAAAAAQSqILAtgFCUQRYVAVAAFAAAAAAACBQyoCgoAIECoBUAAUAAAAAAAIKCSgK\nBQEAAIBYAAFAAAAAAAAAgJSqACAACAqALACgAAAAAASgJUCUtACAAAgKgAAUAAAAAAAABLLABQsA\nAAgVAAAFAAAAAAAQsFSiAEUoASgIKQAABQAAAAAAIAKgApRAAJQAEAAsAUAAAAAAIAKhYBRUAASg\nAgAABQAAAAAAEAFSkAUsAAAAIAABQAAAAAAEABUogFAAAABKShKSgAAAAABKlgAAqFgCgAAAAAAA\nAAAAAAQAAAVFQBUoAAAAALBRAAAAAIAAAAUioBUUAAAACiCwAAAAAQAAAAKRUCiUAAAAUEFiosAA\nAACAAAABRKlRRKAAAACoKgFIVAAAEAAAAAolCKJQAAAAWCpYsKRRLAABAAAAACgSgigAAALAKgFg\noEAAEAAAAACgEKigBYAFQqAAKAQAAgAAAAABQAAAABSUBAKAlAIAIAAAAAABQABACgUAQBRKACAE\nAAAAAAAogoCogClBAAAFlAICWAAAAAACgAEUoEFAgAAABQIBAFgAAAAoAAAAoRQgAAAAWKIBALAA\nAABQAAAAAogAAAALCiAQAAAAAFAAAAACxYAAACwKlCAQCkAAAALCygAAABUAAACoKCWAIApAAAAA\nAUAABQEAAAFQKSwAEAogAAAAAUAACgEAAAKEUIsCUQBSAAAAABQAAUJUWAAAsqVKEVABAUEAAAAA\nFAAFEAVAAAFlICoAEBQgAAAoASgAFEAAAAAsUgAEogFAgAAoABKEIqqQAAAABUAABKEAoQAAoAAA\nIFAAAAAALAAASiAUBAAVYAAAQKAAAAAAqWAABKEBQEABaQAAEBQBYBCgAAAAAEoQFAQAFogAAgFA\nABCgABYsAABKEBQIABQAAEAUAAEKAApFgAAEoQFAIAACFUQAAAoCBQSgqASgAEoQFAIAACKAAAAK\nEAAFAAAACUlQKACAAAAAAAAAAAKAAAAJQQFABAAAAAAAAAAABQAAAJQQFAAgAAAAAAAAAABQAAAJ\nQgFAAIAAAAAAAAAAFAAAAP/xAAkEAEAAgICAgMBAQEBAQAAAAABABECEBIgAzBAUGATcAQUsP/aA\nAgBAQABAgD/AOGpcvd3/pdfBuXd3dy/Vd3d3d3d3d3f+iV77u7u7l3d3Lu+13d3d3/mFV3r7a7u/\nwDXaqvfd3/ktVX019alVx48ePHhx48ePHjxqqr/ACyq+kvtVGPDhUu7vYVHLlytZXGjHjx41X+RV\n8itVXtqjAwnLleqqqpl3HXHjW7MjNyMrNcHBK/zWq1VV2vpRiYRz5aqqrV3ON3x4VGJVVSVVSxc8\nc3JE/zx9AGJhbluqrV2SubmYmE5Xdyqr12ZWn+dMW+lAYW5bqpd3YcXyKYByuVxqXq7vVei+Ur/A\nDdVvdBjhHLdVFuBVOTKDQcavS8pXGu9VXXlf+OX8hVXYY4mLk7qqZcMeLhaZZGNEMeN3Lcq41uq9\nldb/wAxVV2GOFuUqqoMlQx8dZZUtGKmNLrlK73d/Csf8uVV1RjjjllKqqpeShhi5wGU5AY3KWg7X\n2qqqvcJ/lirsMcQyyqtVHJYY4mWZjpbMduUrrfou7u5XHilbvsI/wCUquwMSLW6VVgTPPHHWWQBp\nZXS9XfvvVJVda0P+TquqDHFj2VVhsNKY7cgrtfyKSu4n+TL0xxj1IqrAZjjUYpjpV47uLfx7vdJX\nYT/ACRXdY43fQiqsNGIRbDSwOt/MqEpNX0xT/ImLoDGPUFVYDMcQjlA05dl+BXtGXcRK6if5Cu8S\ncugKqwJgayygacg6L7gMDDgnrd3Yy4mq3i/4+uwB6gqroGYkWBcUOi+8TIYxPUxOg7Ttjl/jrs1i\nK9AirrElArA0odF+EZGTET1InrqOscv8cdGsRj0CKugpmEc1DTA2r8a+Sw9SJ2Ogo6Ef8aWGgI9A\nBVuqGZOOTAjFDa/Sox73oSI6xT/ABw1iKuwVVAcYDGBDS4kr3V8E9DGOj0jE1i/4uRYExGLsJkqa\nvEjs0oSn6E9KMSHUiaGJBH/ABZ0bxF3QZMdKAbIRYAL8B+WjHuRNESqxf8AFlhrEV6AxYqzA6EuB\niLF+qYxIR3RHRDTsf8AFF2BGOgqZMXWONXbDeIaY/CflpSQ6IRCJtmL/imTogRdACzJlACx1iKFB\nkvw35aMYwiHbGJHeL+2PmMYQmIadADMmLrDBdgwmAY5PxH5jpIMqIaIx0RMf25H5eTKA0sIEWZOs\nRF2axxBmSvw35j0JWKibI6ZRD9tT8tgMx26xCKpMnXjxyyjoFDEJmvwDq/MepvjkaNOsIh+1NZfJ\nJkzx4sBi6xI6yZWGOe2EUmBFfjPzXpjKx1luojByP2oVl8rNJiBiZLoAYsylTxYeXM2RmJiLkr7j\nuvVbu/hvbGIiQhERmLD9mAK/KZgZTEI6JiRYxjMBdmqxLW/gD0Xq9h+Cx1Y46JlMiYxIjMVh+zG/\nS/BdYGcJnHRMRmUIxK8Pi/6U0aYTEVfhDd31flMdEx1gokFIiRh+zPW/ByZ4y8Z5F1iAx2zDHE8j\no3iPzFflumEIQjGMIOkhD/DmZJMJiYGTKxGMIwjPAebKMISgxxyX7djvGY7yiS8FMhmMT9ifMYzE\nyhCOiYjGEYwnhx/6WMJRPHjkIj9u7xmMomQ7wjGZQi/vH4OWvGeWBnHRCKx0uJhPKusSV48cpWb9\nu7xmMAjEdYrGJiJ+zPlZ68OK4zyusSMIsynhxyYsITExMtZv27shMciImW7YzGJ+zNvxmZQnimEw\nmbrGMZTCL/zn/QxmIFeM0r9w9MZiEYzLR0x0n7E0QMj4rMpjGYzCO8dZRNE8B/0OsYa8ZkzyP3DH\neM8cSMyIadkyP2BDRCZHxWMwnkhMY6IRjEIwnhcsnWJMTEyjM37hjvGeNUjMtEIwKJlH9gQ0QmXx\nnWEzhthpcDyQmUJkkySY6wxjMo/csd4wnLBY6sjohMo/sCEIay0/Ey1hLJlpmOsnA8rMpjPJCZTG\nYY4TAdZvuu7u/pmOyEJhFd46ZhGKv7AhCW6fUerLWMxmMz2RWYzJmUxmbGYy8DCKzyPsu7u7ux+i\nqmOmYw1jGO8IxmEyMtH7Agmrj8TLeEwmbq3oTOYYu8JnljMZlMnJ9d3d3d3YnxX2kpHTMZjKxiI6\nw0zEymWj9kNy791jHWUIzGYzLowmWRCMxxt1jMnFxXJX0XfRZfQh8Z9zMtkxl4ykdY6ZjGZEP2Bo\nhGPvdWOmYmcwxrLWJnGYzIJi3p1jqxch6va7XrQHyHY+i1WqJjrxkZkTGOsRmQn7MV63d93pcZi5\nzxSuPEnl1hM0mEZRMtENCw7Peqqqqut3fw3d33dEdY68emZ6xjrFYx/d1XoepGYF4Ahzyyu8ZmUT\nA80HLRMYAJ2d1VVVVVVUOl3d3d38pjLhMZWDa56wjGYxjE/eVVdXszx6AcMss8lwyy8WGeOUHxzz\n7QMZhijim2OqCqr1Xa3d3d2N38e1dAGMvGKqzGKzGWy/wB7SV1erPGk8eGWMdZ5ZnhMjKePDHHzZ\nUhMphMIxmW3VVXtu7W71d3YnyXRDd4xjsl3iqv7I9YUxlV3IwmJgeWBUzc3wTKZTxQPNCLgeWYGA\nmUyl3664uKVVV6yHyWMJj0wjHo6I5OQ/u6JVONTj2I6xmD5oRgeeZzwrllPCB5oGU8czcDGMymTq\n7v0BVOPHjx4uLilVVVs+UxhDpjGOjToizGH7q+V4KMugccsa2RJjMMs4awn/QeSeLHIynhZ5YOT4\npmYzGLlM5d3YkOwBVUlUiIyvRd/HdnQjHZHbrH946xbQxCqyxcYRhEmMxcpc8T/AND5J4J5JmeLM\ny8pGeOMxmMIubcuyHSqMQrTFu1tVl3d9bG7+Gx2bNMdGmMY6x/WvvpJiwIaymUdMJcxmI46xfNl5\nDwTMzmMwnmhGYTGYwzFc27hCGjRu7tycnLlycnJbvtd3diI38FjDRDRt0aYxjrH96iYgVvJzjp6E\n8LlPJBxPNhnP+d8uOZx8Z5jEyPGENszb0Qho1d3ycuTly5Xd3d+q7EbG7v3MYaIaxjp0dGOj90dA\nxCVUyyXLsawR80xCZzyn/O+U8kHB80xaxKJSE8pshCXfLlycuXLld3d3d3d3fa4Qg3d3ftehDQPQ\n2x0Q/dGyBDeSsz9Hix4eUwwywmT/wA75Z5QnjfKBMG8ZbrzdCEqOXLlycuV3d3q7u7u7vudLshD2\nMYSqCBGOzbHRD90Q0QhDWWTlrPJ7+CEyMSgZ4p5YyvFM94RmMylzzFVogrKr5NdiHtYzGVQTGMY9\nSMdEP3RCEIQl5ZOWlYnbwpo1mZTGZTFTxzIqjWDmVPN3flVKrrQHou9usdmsYxj2Y6P3hCEJbmur\n3VVWvG45WN5OZjgnjnkPHGJVExmZGeX00/DqqqqqVWz1LYxjrGENkY9mMdH7wly3K5fcgOsXFymO\nTlECeM8p4mZGxz0zISq6Xa3d/AqqqqoK63d3d2q2aY6IQ0aY6OjGOj97d36yYzyEHBYMEgMxPJj4\nXFy06HKEdZS1u7u7u795DVVUoKYxbu7u75XcdGldYwhojGOjox0fvr92M8uiYIjyEWZzNnimETTC\nZQjFyy5Xd3d38EhDsGmLu1vVkIx0NrrGGiEY93Z+9fSdxzjrFxXQ4s8jlDLFwWZS4RhpnlPj0Y0F\ndTTMl6OjToIx0S94w0S17MdEP3r8A06xcHLV45Y5ZyyMxWeSCwl6Z5ceHCqqqqq9dGIem7mQ41GM\nIGg6V0JiG79Do/fPwRdExWO8cs8sWZTxuM8kIpHbEPH/PLw/wAnxPi/l/L+fHjxruAVVVXpuuLiy\ngju71XTExIae93o/wAPYx0OMTQ5OOk8SMQ2y4zFuVXExMP5/wAnw5eB8T4+PHh/P+fCoFV7BGssO\nPS70EY7xD1sdEP8QdkwcjdUQzIRmYRhHbMCq2J1yMsXAxMUTLRCX7hxyjjlh2CMY6DGEPS9D/EaT\nWKR2dMpguWQaJltmGV3drzPKeUyfI+Z8r5Tyc/6c3J2S7NPtM8Vxz8b0AIrsAPUuz/EWI6HBR0Qj\nCZvjTTCMejP6f1/s+Z83Pny5cuXLlyc+WBk9iX7scsck8njdAEY7APUx2Q/xJHRMHpWbo0TKEYdc\nsePHjW7u+mBT3u/fjljnkeTCgjF0AHW+q7D/E8h1i4qQ0wlsxbQjCMOlOLi4uDg4uPGuniMn5OLj\nlkOOldgbvursh/iiJCYOrtl3MHWW3qN6tiyqpKnjMnoR9F+wRRiuyEPVa7A/wAVzHWLiuruJi5QY\nhoj0Jw4uKJVVp0TGZdSL8XFmQx0Q2vQjtXZD/FkTWLHZMg1hBjvIOmDTi4oiVp0GMy+bipkaA3ez\nor0D/Ba1XuyHWLhkmxzBZcQjDrf9Obky5VIlYwMvlXrFmQgBpet6Yug/fVVVVVVVqq9iZGsXFyIw\nmWgxcWMNZQd13YjiYkz+di5RA0uzS7XRD4d3dy9Xq7vrd3d/lKqq+PkIwcUibcYxhHTCJ78ZmfNx\nWJpehF2uiB7bu7u7u7u7u7u7u7u7u7u7u7v8jVfHZlikHHKJ1IayBjD1PXGZj84jq/QD0D13d3d3\nd3d3d3d3d3d3d3d3d3d3d/qkyx1i45J1YIxLFB2darZKT49dcVi3o3erlQD1Xd3d3d3d3d3d3d3d\n3d97u7u7v8AVZCaxyxyTuL6R9PjfLj8mq1jGOiG3odA73d3d3cvV/OsR/VZYpBxcck6JjtNZQdJB\n9GD5MX5uMY6N30FYHotZd/Un6pmWKQccscqTTLJcYMQdJY9xwfJj8p1jGO79Bu7u9PS/fVfHuxEf\n1CZYpBxyxyiaTFhpNjpPTg+XFNV6D4GMd3d3d9ru7vld39cIiN/pkyxTRljmKRKslyokHSejFxfL\njofkYq3d38WvrbMuXLly5crv7eq+nTLFNCZY5VSZF4o0goI6Qa6jizPD5j9ifJu7u+XLld/a1VfT\nJlgmrHHO0RxMjK7SII6S6645YZZY5HpI+q76srR8m6+HfS/k3ZkZX+dTLDLGpYmRlEyxg4KcrQdu\nrrVQcPJngnwb73e7v4zq/tbMh/POOWCaEccjKssExyUaIgxlyrqtkw8nk8eWPvv6V9Npd+qq1XSq\n9b7cX55+ATLBxSCJkZGTi4EvFu0HkmqoaSzIcPLkZ+P2vur5N6I9CEcZd9h9d972+0h9AfgXHLBx\nqCZCZckzxxRQWIZVWkhE4Ji45GWfhSPpetfROjoSqYZXHBPlVLX2kPsbv69HBwcal3d8sgmOe0gy\ntUZMqWOHkzxcPVX0jLWGmMIy9GQmQvjfE4/Gu795Dvd/GPwtODg47u7MnETMaiDfR1bCXimbjl40\n7YxPoru1gBpTTLux5cjM82PlMX/AJ3wPi/m4caqqquPHjUu7u/gjy5cuXLlyu/ztOOXjfGnQeUJy\nM4lcrl8rvVEEeS8XDi4xg8nHj8273dGMtdEJmdL6CZ/0/r/AF/r/Tnz58nLlycru/g3fzz8bSOD4\nnxuNasbg8zO5dpxl2Ri8jycuXK3JWMEZxfH/Nx3frNVxqpe7ucQu77Zdru7+gu/2acHxvifE+Phx\nqVU5GfKXaURWOru7snGWZGQkJx/m+F/5/8AzP8Ayv8Azf8Am/8AN/5v/N/5v/P/AA/j/LhUdPWqr\ntfQmXeqrV37LvpVVVS7v91VVx48eHDg+J8X8uMMr1eqqqMTFl3qzI8h5MczKxu4dWMYqrcrhw48O\nMvpd9iZSuHAw4ceNVuqlUSgca48eDjx48a3VVVVWq/Y3d3fJy5cuXLly5XdyqrjWTfLly5EMZUpI\n9LMzy4+c8x5f6czMz5cnK64fyfFw4ulXSrd3b3AxMeNfTVXGkr9Vd25cuXLly5Obnz5cuXLly5cu\nXIzMrmUy07xwCGl6vazM8h5TzHlPIZ8jKx5LauTk5uTm5uV3t6UYmFS79FVX0KJX6W7cnJycnLld\n3drfe7u7MjIyycm4GOIY48WK9WPqu+XP+h5Dy/2/s+X+r5OblyvtUYQDEx61VfWVUu7/Pq5ublyu\n/RVeu7MuWWgCsAMplHpVJXwDs+plGIfcV1r5r96rm5cr9dV77vGBCGT5Msq1d3Lu5Vca9NaqqY+o\nxD88/eLlk5fPYYY46u7u+ydqcarVaqUBiivatAH5g/CZq/OAxMaqq416qqqqqpHHVVx4mPGlV61F\ngB/jvk+dRiF2S3Ll7K1VVVUiEDEDI05LqoFbAPqX5Duqr5b95meh+GBjLu6NVxr4uWhHldquiBUv\nRA+qflXd/mMhH4b2CXejpd3d6qvUdcnd2uuJhUuWoB9Wv3D94iJxow/g+Jwr2XcrsdLeldK9qrtV\nhiYxVlrDAA+Hfxl+VVVVV8l+9RxqYp5HPHJyyTH+X8P/O+PI2nHgnoCtPx7W7u5xC3K9JxCx+sYw\nh9s6fvkcOJBFyseRm+RXs5dqCvg36VXYDd3d9j610Q+2dP4GnDhw4cePHjx4cP5/wA+GXou/RVeq\n+yrq7vlyu7l3d3eMPrHR9gd3T+BNVVSqqq3kr6K+Taqu79FVVUH1x9gd38Jd3u+2eXvfTd+i1WPo\nqqqq3difWn2B3fyGTl8B+Dd3fSutV6hv6o+xO7+QymXsv4V2t3dwOCbrvXUbv6k+wId38gzM+Zd3\nq7vRLZVVXWvRY/T18kKqviEO797d3duXPnz58+fPlyvbMj593qqoJVVKqqqnGqrsfgKoK9B7iHdj\n9tfLlz5OXLly5Xd3d3fLlyMjIy5XlH4Vyqrtd+oNVxrQI6etV97VUFV6T2kId2P2VuTnzc+XK7u7\nv2hxZdreq412o8R/wA//mfB/LiiO7Vb95LXWMYnFxqqqq+6qqqqr2Gn1EId2P19uTk5cru/iExlZ\nRlurESDcoKDENsRGMVyclv42OXK7V+8qqqqqvlkId2P1quTlyv5JMdZL0ZZkZDcIaIJ0cUYr8i+l\n3d/bVVVVVVVXwDb6j0MY/Vq5OS38zHK1ejsREyMuRkZGQ8uXJyXJY7qvxtVVVVVVV8U9p6WP1SuS\n37Kr4JFeqVVAFauxMuXJyVjuv1xDT6yHdjH6lcm/ZVSqp999DHi4hxqqqqqtXa2v7Ihp9ZDuxj9Q\nzJv2nZ+JdwAqqqqrd3a3f7Ehp9ZCHdj9RlMn3EPlkCVK6MYt3f0Bjj48fEeLhlhlgn4shCMfWehj\n9TlMveQ2/HAACqqVpjGX9BQYYgbZnH8aQjH1noY/U5TI9pA+SABo61SJl9GTGEGMZlMvxhDTH1kO\n7H6lmQntH49AAepjMh+iJiiOlyj+MIaY+sh6H6pEquPHjxrsN/DqgAA9bGZRlVVemuPHjxqvYQhB\nG4xjKqq+lqq+eQ0x9ZDux+qpxSqAnHjwfE+H+X8v4nh/h/H+X8v58HGqqq61VVVVXtYxKqqrjw4f\nz/meI8fBwcXGkr2gBCG2MRN1VVVfMqqqvoCGn2EO7H6yuNaJQVKADaRiMTpVcePGqqpXvYiV2qg6\nMYx3faqoKAO6JVbqqrjx4cP5/z4car31VVVV9CQjH2EO7H62pVV1IS9undVVVVVVVVV8OqqupDdq\nqsetVVVVVVV6aqqrsGmMY+2qqqqqr6M0+whDux+4IbYxj3qqqqr5FVVVu7u2O6qqqqqqqq91VVVV\nVR0dPeqqqqqqqqvpcdMfWQh3Y/cjdr9jVR3VVVVVVVfHqVqqSqqqqqqqqqqqqq+mx0x9ZCHZjH7G\n/Rd/apVVVVVVX0LupVVVVVVVV9VjGMfWQh2Yx+2u7vb9ox+rfRXSvrcYx9hD0MfuLsfe/Tv1N/ek\nY+wh6GP4p/w9j7CHoY/hzTH6O7u/wBQx9ZCHoY/hzT/AIgx9ZCHdjH8Oaf8QY+shDuxj+HNP+H/A\nP/EADEQAAIBAwIGAgICAAYDAQAAAAABEQIQISBBMDFAUFFwEiIDYGFxE4CBkJGgIzJCsP/aAAgBA\nQADPwD/APHVd3/vcP3Sx2QkIXBpX/synYp8e3WeRIVmMepI8D2G+bIGxjHo8iXMQmUtZFZTAhr2W\n3alHgfC8DfMRSiWN2SurIQhaGMckj3KX7IbN2JDfDlHkpQ3yKquYkJctLHdcSBP2JNlSN8uG2JZY\nuVI6hlK0NnkS0oVmPiT7CVJPDcwJZY//k8kiHuIdkrM8iPA3dC479bxrkS5myJ1ReSOZ8Twbsbwh\nsS5ngb5iV/IkPYb4S9lKkb1tWbJPizYbzURyG+YkbIb5iVmzyeBv2rHMjC4DXIbyyTIqcDqEjZW2\nQ3zEiBsjmeBvWrMfsuRUrJOqNEiXMh4G8u6Q28aFueBvgMXFXsFJEvVGn45Z84IvshvmRdLCG+Av\naCSJ4UZZuVV8xK88tHm0617RSRPAm0ZY27RaCcLRA2efbCpROqLTbdjqZBFoHURaSOQ2R2+PVKSJ\nfAm0k4IV0j5Mi25sie1TwI9TZIRPAm0kIlzeBtkW8ngnpJuut29SwpJ4E2ki0KySJ0T00Wn2dFpZ\nCIJZHWNEiZHWz6hkjHAgnTuZgb0T3qGT6fkhaovLvmLwvbcLTvwJvCE+YtrSyNM+1s6JIUa9iLRw\npZsu+wzHpeFokha4tJBJJC1yR3eSNe3peeJLskN23tjTJClm5HdofBn0rC0xxJIvNoVtyWQT3OHq\nh3n0xLvPAhEkK+YHSo82m+5LtsQjbvEO8qGQRbHpXHGl3lyxvL07W8m5Bv3SbStMMm2PTGOFLIV5\nZ8aEtMKSSTJBL7vsZtm+NEr95x1km1scCES7qJZLvm21oVtumx2HBFscDYh+ksWlkubbcOWfCmNM\nWm857zOCMCd99EMlT+7TZTjrIobJqJZlvRjW+Z8qo8ac2hd8h3zbGmV+6zZLl1WDNopghEUt65tN\npeB0fj/AKRLnRBJnv0rRkh6IZn0hi2TkjYxGvFoRJH2HKp/Q82204m2LZJXpOajJtoi2dHycHxpP\nn+R1EaoXfoen66cekM2ilsxNpb1b6JqPj+N/wA64U/omIJ0ZIfo/NvjQl5MEUt6sGLRSZIRlU6cE\nkIz33Nsac6U16OxbJ9kvFooS86swLa2IJY/jk+VbeqEbWjHfM2zpipacekMk/kZkyl4WiFbOTFsk\n1Cppb8apZCM2z31bn205JV8mPR2TJ8U6vCNzJNT0Ytg2MjkyRRHm+dEK0SyX3zBiSHqxeKrZ/do6\nfJkj8L/AJMEZM6NrciWRkkwfdL+NGL4MGO/fQhmOBuYtn0ZkyfSlGDBm8EE1GJNz62+ss+VTqehk\nshWwZ79sKDHAwY/d8dRkmpLxaKdGLS2yKDB9bNUxplzfNpf6BK0P98x02LZJrbHJFGnGDA5gwcjJ\nhWzaVNsXhfoOOBkgx+7Y6bFsmTJhasImps2MmSXbNo/GzBi2Db9J+37yupwcldfGLSyDJ9kfZol6\ncJWxbMEvv2TNs3zaHfPpDJgihsyYtFskJmT7E1CVE6ZatgyS2Z79m+b5tm+TFs/usLpFbNvs0SiK\nIZkbtCS3t9hfG32PsfQnRNRkwZ787ZvLvm2dGLZ/dZ6bJDG6nVsRkbpbJcmJEiYJcH2hiiDc+xkf\n+FO5jRkyY79kQtGXfOrFs+i8ksz8XykpjwSnDxaMRaeZHImoUEUkCdWSPxQYM2xbJFlH6FDvngZ9\nFYMn2R9hwYtknCHTHhkPAquYk4IRg+6PpZO30nRgj9AzbN5erFs+isGSKkZMI+NTRBNpSRmbfcy0\nfJQL5Jboz8RbDG2R+OL5/T8+i82mohH3tkyNNIx/qYF/iM+zH8mkfaT7uyE2TSkrRkyYM9Ix9txw\nM+hGrPmuBm0MlH3tkyL5U/0b/yQhVVSfY3H8iam7ZMDhK2DJgz+hQvRKeDN5KlyFVz5jpcPgwiXe\nT7J2wfYzbI1VBCMkoiDBgXywSZ/QsacehIPkv5J0Tg2ZF4dsWzbE38mVb6i+cEVQfFwyT7WyP4nI\nwYMmD7foOdOfQ86U9WLyyFm8MVUQfUwf+RyfYXyJUn2tk+v+g8GCKWZt9ulXbM6c+hsmOH9brcTp\nPjgkTYqUqkYmz+U2cyP4n2MGTBgwc7/AG6B9xx6Qi2NcEDZm0M+X4z6p/xaWjBLUkKDe1LItjR9/\nwBBx6ShcD5MhYNxsayTQ0TSv9RSZRNEmVb66MmBZX8iMn21MehjGMYxj7TnTL9JeRPgZs20iESQ4\nZC/pkVDiljdMH2MH1ZkyZMswRUyTJnUx+x8E8SHb7u+UzLRFQ3R/QnS5IqtgzaGZdvs7ZOT9mwN8\nWGYPtN0zMm4vg7RUYt9tP2vj9Ix6FniZvi0IbMCduaEnDN0YMGZWnN5Xf8APpqKtErTkhk1MhQbE\nrQzOnPY49a5T0Y05khj+UobWSKmrYenF8Ge/Y9NTTOiVGnAoRiTEEV3h2zbGiKuyx6zlNaMmbubT\n+NxklWhpm5KHM8CbMfYpH6ygzolaaqVC3FFpRNImiabTfN5wZsrMYyoqGMY+rfrWVoglZMWcSbW2\nRDvjhKyKSnwUsRBF2Mdo6SRrn6030tWaTS3IrJGhTPAlcRErQhGejiyZI16zlanNmsmJJRKM2yZ0\nQ4ehWSEU7spe5SubKfIp5iEUwInp45idoyvWOdWxtal0pI2vDvidLWGIpETqYxjGMfUOliqUq269\nZStEZFh3iq+b40zyGMYx8KWfGnqXS5QqlKtuvW2Gh2wShk6IelCELhvq3S5QmrR6snTOjJDMkohx\nfN5RnosdZDJVo9ayQbEP5IlTqzozxcmOt2J9WytMrTKsmjnTfN99KeBCfCyY7BD9YzpjK3tD+SN9\nrTfYh6Gs3nStGTBnrYZK4kepp0yrSjHxZteSDfoIZgz2DPrGURoVocm6vDNycEdDy67JK9ZpkXxe\nMEX2tKnTvw8n06+VxJI9QzqnXKNtOdMPhZPkoI67HFn1FKItF5I0xklSYtFpUMjj4Iq/v2nJOppw\nQ9O+rbgwz5USua67PEj1VJOrA0xmLx/WmeFKyfGprrc+uE9O1n5tGbwyb7ong5PlTK5o36zPrlPT\nsyDdaYJvuuDsYghyuT6yPXUkkEjRBKIwzxfe06NnrgzIqlHkdLh+0Y1J6IYmJE87+LTeSOYmRp2I\nFXTH/A04fPvEemExrUmTaRqyahjpYrbkE6GuQnrUQL8i/kacPvU6sdxn9fT4CZJI1aaZE+ZA+VoJ\n0+RMejc2KfyKVhjpcVc+tXW49LyNa5EyDEDTgbQ0SSQJ89EjVkxq6GnGxR+WmGVUPP/AD3nGvx2/\nP7FPCT5E5RBJF4ybMnloTGuZPIW6N0NWaclNahkOaM/wRz7vjQiLp8zwNc+hfV5/ZE+Enlcxo8id\n/A0J6VsNFD5YHtkW9oHOSn8n9lVPeYvF8CfMpfIq2GufTL95Ts1rXJm6GjyJ38D3E9MjXIe4jxZo\nkpqI5ZI568wNdnnTNpRGtpnkpeGj8VRQ+TIGVFQxjGMYxjshfv6dmh6WhPndoTFaCOYnaBCuhlSI\nHvZMpdmipc0O0MTQnyGuyN2S178JoqQ5GPcdqSkQhC9DITs0ND0qzQyRWdkzwMelqyupFHMTELQm\neCrYa6JjutbHuJHjg44C7Cv3dCuxjGO7Q7Jni64bKmeeBSykoZQJnhj8jKisqKh2QhIXBdlxca2M\nYx9TAv35CFrTs0VorQ91rQhXSHGqLKyF0LexUVMZ5EIXQMY7IQhCEIQhC0U+CjwUL+T8ZQfjKCkQ\nhC9HoWhCPGh2bElpjUxoaPIhCFZCFduyfMpRT4EhW2vjjsYhL1gtcnnoWND0ITshCFdCEIQuK7L1\ntjQ2Rojo2MYxlRUVFTGNjHx16ajsE2m+bYFpYxj7E335C9ATdbiR4G9xCELUhCF10evmyOxx6phd\nhbFZjHZIXTsehCXqrHXN2S1NjH06EIU+rpI6t2S/dl+vR1S1LsjZ5EvVMaG+RX4K1sVrmmNcN2Vn\n0j6eTyJa2ypkeo50QYJsoyUt8kUPmj8Xg/F4Pxn41sULkhFEctKXDfXzZIWhjPJShep0zwNDEQY0\nO06V22PWqFdjKioqKhjshCXuTHDhEv/ADv594Z7PJSt/ZGe0P1orKy1oQhC9toXbn1L9SLrGuAxj\n1N7H5HyRW+cFXkjmxIS5e5UIXgQuyxaf82jHpXvBjH7inmU/wCwjNvJQtinwU+CPR+f1uf9ntjH3\nR8FjGMfq5PKEUspFdjKh3jtKEIQhCKFsU+CnwL/ALA+PeGP+lh//8QAIREAAgEEAwEBAQEAAAAAA\nAAAAREAAhASIDBAUGATA5D/2gAIAQIBAQIA/wAhW227Nttttv7l/VrFJJJKJL6BbAJvZJKNuEfPG\nOwCd3HF9QYTAE4rJRJRxfRkmwFlCQFDyJfOkxAaEgONR8iWoPywF24Bu+ZXB+SNgNCYBcnqEXB+S\nAJuSABzCI7HgB+QWhNh0G9Twg/HiE2JiPZOwhEHxwBsAbiwsB1iNRCB8kYwLgdcwjQXHxRsIbmGA\nWEER7B1Fx8WrAEmwFwCeI8xsLEe8OYykAG5hlIqNqQSe2IIbCED2xHyGCCGAG4FxCeF3PKbCxghH\nxJguIbAf0NgD3DYQQwWPxBgtVr/ADFREQBHbNqYjYQwfDmUAQwQ2MphggBlR7oghhghg9sQ8ZlMF\nhDDYkwSkGHvCCGGCwh9kQQ8ZggsLEw2QB74gjMFhD7IjPGYNBcwRyo7Nt9JWEEMEMEMHsiHkANjB\nBKYYIISTsdB0nYQCoQwSr4YSixhvQDBBDskkrtvoCCVQQwSr30rGwlMoAsYkLCUw6JatttvmAEEJ\nhIJPvJLX+UMVUdMqsJSId0accUlcchsLm7fvAkJEWMoJglcVJrhggjJcFkArnZviNhcwQwwe+RTF\nKtKZUAarGVQiGMmCC7yNWWT0bBB4TYQQ2FjB7osgISdKYQKapTKtDEbC2WWWTbbb1b4EIIdDB74g\nhqhN6JVaoUyqCIQ2q6w2OghuYYPeEBNTuiBGzADKYobVDmSSsI22xCRBc3PwL2NgWCbUQw3qLbb4\nBojG23DASRBBDoYPfPFSTASXSatKwklugFoYrjUQbGD4gwE2cEYuYKTT+f5fif5GhY4gALZEbjY/\nFGwujAdBYwVAkkxk8LEqpsLiDcfEkGUnQQ2NjMjW226RyA1UgQ2A1Nh8URBY2B3IISg3ezIJg2Nh\n8WbUk6A6CGkhQ9IwbkwfApRLaoQGx3dyEecEgcA+NMIB0HGuwN222222222/VIMB0HApTCOsNW22\n7vhbB9UiA3HEJWOYbMbPqN+qaYC7PRaAkczb1bfYBbb7i6hpgIKIBsNgTD6jbyfaPVNJEFTNIOyg\nJpXrMHmHgkGmAiqEA7IEwjtnRdYc48M0mmNxtXceWJHWdjdirJYGlcD4AW2/PNJoNKbbbs08rgrF\ncKu4o4IbuAvLLJt/BJGnDHHFR2MUSgIqBWOH5/n+eBFjEtzFjjikksUkkl7beWWWWWWTdjHANWKh\n/Qf0H9M3FCSWS7pLrL03kam29m8jAAEeJ5ZZMnVAfGGp84EZqeyWiR1XxtXQAXAlEQscUSTovjjD\nyrV8RsyYrj5EhU04GjD8zSkl0SW4o4gPDSXIfAWKMbiR6ZNm22x6Z8gnpE6pKDrjsDQ+RV0W4BZb\nA9I3HYGh8Btt3q523EIlZLxElxDQ9p5ZZZNttsFvfHHFWb4RCEou8kugeuzUS+OmGGzBBsLkGEk8\nbb7qSS3HGesS+YR2NhBGDlkSfLS7B6pPTFkklGT7I5D1DD1RAErGPoAClVDxhuIND1DCNhygLUjo\n0wWqh8YbjU9UhLHDDEU44pLRJLYxJY44fnhitxBGYRZdBJc4h2Gp6yWgsYbJJJcSVxc6pJaJKyWJ\nC3SS6Ih2Gp7gMJ7JskklwrQxJJJJLpiHYd999dhLsHYeC38qND4g6j9saHxB8T/AP/EACYRAAEEA\ngECBgMAAAAAAAAAAAEAESFgECAwQVACEjFwgJAiQFH/2gAIAQIBAz8A+4AaBC+HIsrcBtzXdvTV0\nBd+g+Ms4e7Mn+lNhd3Km4Rv5Q+jW6NXK6XGdpdOcx2OKVOJ1bwvcY3/AB7JFNjSVOJU4jsUU2NZx\nKlRbp0i6TgJ1GIJ7NFLlOmx/FCcphc5zClRiLg2JUlQmKjEXCU2PV0/hUqLlKjP4qVGs22dIbEZj\nE3B0yhRmMTbIvsbxcYxOwQQyUbRG07hBBBBDkeylFH9J7MNY5ntUc71ydztFpfni1PzteOtrflhk\n1xbfzC2vxsvNItwPEyHiTXjov52AdUCj0RRpB5gh2Ao0kIalHc6hBeFeFBBBBC0ngKOoQQs78xRR\nR9zI98wfVBN1wbqUUUcFH50FH55BD3GKKKKP7B9jx9O/wD/xAAbEQEAAgMBAQAAAAAAAAAAAAARA\nWCAkKBAsP/aAAgBAwEBAgD5tcXed3M5wTnaqvSE0hvS+NVbiAaPFV5N1dgf/8QAFBEBAAAAAAAAA\nAAAAAAAAAAA0P/aAAgBAwEDPwAWA//Z\n"
  },
  {
    "path": "includes/donation_banner.php",
    "content": "<?php\n\nadd_action('admin_notices', 'podlove_donation_banner');\nadd_action('wp_ajax_podlove-hide-donation-banner', 'podlove_donation_banner_hide');\n\ndefine('PODLOVE_DONATION_BANNER_OPTION_KEY', '_podlove_hide_donation_banner');\ndefine('PODLOVE_DONATION_BANNER_MIN_EPISODES', 5);\n\nfunction podlove_donation_banner()\n{\n    // don't show if user has hidden the banner\n    if (get_option(PODLOVE_DONATION_BANNER_OPTION_KEY)) {\n        return;\n    }\n\n    // only show on podlove settings pages\n    $page_key = filter_input(INPUT_GET, 'page');\n\n    if (!$page_key) {\n        return;\n    }\n\n    if (strpos($page_key, 'podlove') === false) {\n        return;\n    }\n\n    // only show when some episodes have been published\n    if (podlove_donation_count_published_episodes() < PODLOVE_DONATION_BANNER_MIN_EPISODES) {\n        return;\n    }\n\n    include 'donation_banner.html.php';\n}\n\nfunction podlove_donation_banner_hide()\n{\n    update_option(PODLOVE_DONATION_BANNER_OPTION_KEY, true);\n}\n\nfunction podlove_donation_count_published_episodes()\n{\n    global $wpdb;\n\n    $sql = 'SELECT COUNT(*) FROM `'.$wpdb->posts.'` p WHERE p.`post_status` IN (\\'publish\\', \\'private\\') AND p.post_type = \"podcast\"';\n\n    return $wpdb->get_var($sql);\n}\n"
  },
  {
    "path": "includes/downloads.php",
    "content": "<?php\n\nuse Podlove\\Geo_Ip;\nuse Podlove\\Model;\n\nadd_action('wp', 'podlove_handle_media_file_download');\nadd_action('podlove_download_file', 'podlove_handle_media_file_tracking');\n\nfunction podlove_get_query_var($var_name)\n{\n    if (isset($_GET[$var_name])) {\n        return $_GET[$var_name];\n    }\n\n    return get_query_var($var_name);\n}\n\nfunction podlove_get_remote_addr()\n{\n    if (getenv('HTTP_X_REAL_IP')) {\n        return getenv('HTTP_X_REAL_IP');\n    }\n    if (getenv('HTTP_X_FORWARDED_FOR')) {\n        return explode(',', getenv('HTTP_X_FORWARDED_FOR'))[0];\n    }\n\n    return getenv('REMOTE_ADDR');\n}\n\nfunction ga_track_download($request_id, $media_file, $ua_string, $ptm_context, $ptm_source)\n{\n    // GA Tracking\n    $debug_ga = false;\n    $ga_collect_endpoint = 'https://www.google-analytics.com/'.($debug_ga ? 'debug/' : '').'collect';\n\n    $ga_tracking_id = trim(\\Podlove\\get_setting('tracking', 'ga') ?? '');\n    if (!$ga_tracking_id || $ga_tracking_id === '') {\n        return;\n    }\n\n    $episode = $media_file->episode();\n    $title = $episode->title();\n\n    // see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters\n    $ga_params = [\n        // Basics\n        'v' => '1', // version\n        'tid' => $ga_tracking_id, // tracking id\n        'cid' => $request_id, // client id\n        'ua' => $ua_string, // user agent override\n        'uip' => podlove_get_remote_addr(), // IP override\n        'ds' => 'podlove', // data source\n\n        // We highjack the campaign fields for context/source data.\n        // Source / Medium maps to Podlove context / Podlove source.\n        // This way all Podlove sources can be easily grouped into GA Channels.\n        'cs' => $ptm_context, // campaign source\n        'cm' => $ptm_source, // campaign medium\n        'ci' => $episode->number, // campaign id\n        'cn' => $title, // campaign name\n\n        // Pageview params\n        't' => 'pageview', // hit type\n        'dh' => $_SERVER['HTTP_HOST'], // document host\n        'dp' => $_SERVER['REQUEST_URI'], // document path\n        'dt' => $title, // document title\n    ];\n\n    $ga_params = apply_filters('podlove_ga_track_params', $ga_params, $episode);\n\n    $ga_param_fragments = [];\n    array_walk($ga_params, function ($item, $key) use (&$ga_param_fragments) {\n        array_push($ga_param_fragments, sprintf('%s=%s', $key, rawurlencode($item)));\n    });\n\n    $body = implode('&', $ga_param_fragments);\n    $curl = new \\Podlove\\Http\\Curl();\n    $curl->request($ga_collect_endpoint, [\n        'method' => 'POST',\n        'body' => $body,\n    ]);\n\n    if (!$curl->isSuccessful()) {\n        if ($debug_ga) {\n            header('x-ga-debug: http error');\n        }\n        \\Podlove\\Log::get()->addDebug('GA Measurement Protocol request failed.');\n    } else {\n        \\Podlove\\Log::get()->addDebug('GA Measurement Protocol request successful: '.$body);\n        if (!$debug_ga) {\n            return;\n        }\n\n        $response = json_decode($curl->get_response()['body'], true);\n        $hit_paring_result = $response['hitParsingResult'][0];\n        if ($hit_paring_result['valid']) {\n            header('x-ga-debug: valid');\n            \\Podlove\\Log::get()->addDebug('GA Measurement Protocol hit valid.');\n        } else {\n            $debug_message = sprintf('%s(%s): %s', $hit_paring_result['parserMessage'][0]['messageType'], $response['hitParsingResult'][0]['parserMessage'][0]['parameter'], $response['hitParsingResult'][0]['parserMessage'][0]['description']);\n            header(sprintf('x-ga-debug: '.$debug_message));\n            \\Podlove\\Log::get()->addDebug('GA Measurement Protocol hit invalid.', $hit_paring_result['parserMessage'][0]);\n        }\n    }\n}\n\nfunction matomo_track_download($request_id, $media_file, $ua_string, $ptm_context, $ptm_source)\n{\n    // Matomo Tracking\n    $matomo_url = trim(\\Podlove\\get_setting('tracking', 'matomo_url') ?? '');\n    $matomo_site_id = trim(\\Podlove\\get_setting('tracking', 'matomo_site_id') ?? '');\n    $matomo_token = trim(\\Podlove\\get_setting('tracking', 'matomo_token') ?? '');\n\n    if (!$matomo_url || !$matomo_site_id) {\n        return;\n    }\n\n    $episode = $media_file->episode();\n    $title = $episode->title();\n\n    // see https://developer.matomo.org/api-reference/tracking-api\n    $matomo_params = [\n        'idsite' => $matomo_site_id,\n        'rec' => '1',\n        'apiv' => '1',\n        'action_name' => $title,\n        'download' => $media_file->get_file_url(),\n        'url' => $media_file->get_file_url(),\n        'ua' => $ua_string,\n        'cip' => podlove_get_remote_addr(),\n        'send_image' => '0',\n    ];\n\n    if ($matomo_token) {\n        $matomo_params['token_auth'] = $matomo_token;\n    }\n\n    if ($ptm_context) {\n        $matomo_params['mtm_campaign'] = $ptm_context;\n    }\n\n    if ($ptm_source) {\n        $matomo_params['mtm_kwd'] = $ptm_source;\n    }\n\n    $matomo_params = apply_filters('podlove_matomo_track_params', $matomo_params, $episode);\n\n    $matomo_param_fragments = [];\n    array_walk($matomo_params, function ($item, $key) use (&$matomo_param_fragments) {\n        array_push($matomo_param_fragments, sprintf('%s=%s', $key, rawurlencode($item)));\n    });\n\n    $body = implode('&', $matomo_param_fragments);\n    $curl = new \\Podlove\\Http\\Curl();\n    $curl->request($matomo_url, [\n        'method' => 'POST',\n        'body' => $body,\n    ]);\n\n    if (!$curl->isSuccessful()) {\n        \\Podlove\\Log::get()->addDebug('Matomo Tracking request failed.');\n    } else {\n        // Strip token_auth from URL encoded body for logging\n        $log_body = $body;\n        if ($matomo_token) {\n            $log_body = str_replace('token_auth='.rawurlencode($matomo_token), 'token_auth=[SECRET]', $body);\n        }\n\n        \\Podlove\\Log::get()->addDebug('Matomo Tracking request successful: '.$log_body);\n    }\n}\n\nfunction podlove_handle_media_file_tracking(Podlove\\Model\\MediaFile $media_file)\n{\n    if (\\Podlove\\get_setting('tracking', 'mode') !== 'ptm_analytics') {\n        return;\n    }\n\n    if (strtoupper($_SERVER['REQUEST_METHOD']) === 'HEAD') {\n        return;\n    }\n\n    $intent = new Model\\DownloadIntent();\n    $intent->media_file_id = $media_file->id;\n    $intent->accessed_at = date('Y-m-d H:i:s');\n\n    $ptm_source = trim(podlove_get_query_var('ptm_source'));\n    $ptm_context = trim(podlove_get_query_var('ptm_context'));\n\n    if ($ptm_source) {\n        $intent->source = $ptm_source;\n    }\n\n    if ($ptm_context) {\n        $intent->context = $ptm_context;\n    }\n\n    // set user agent\n    $ua_string = isset($_SERVER['HTTP_USER_AGENT']) ? trim($_SERVER['HTTP_USER_AGENT']) : '';\n    if ($agent = Model\\UserAgent::find_or_create_by_uastring($ua_string)) {\n        $intent->user_agent_id = $agent->id;\n    }\n\n    // save HTTP range header\n    // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 for spec\n    if (isset($_SERVER['HTTP_RANGE'])) {\n        $intent->httprange = $_SERVER['HTTP_RANGE'];\n    }\n\n    $ip_string = podlove_get_remote_addr();\n\n    if (function_exists('openssl_digest')) {\n        $intent->request_id = openssl_digest($ip_string.$ua_string, 'sha256');\n    } else {\n        $intent->request_id = sha1($ip_string.$ua_string);\n    }\n\n    if (Geo_Ip::is_enabled()) {\n        $intent = $intent->add_geo_data($ip_string);\n    }\n\n    $intent->save();\n\n    ga_track_download($intent->request_id, $media_file, $ua_string, $ptm_context, $ptm_source);\n    matomo_track_download($intent->request_id, $media_file, $ua_string, $ptm_context, $ptm_source);\n}\n\nfunction podlove_handle_media_file_download()\n{\n    $download_media_file = podlove_get_query_var('download_media_file');\n\n    if (!$download_media_file) {\n        return;\n    }\n\n    // tell WP Super Cache to not cache download links\n    if (!defined('DONOTCACHEPAGE')) {\n        define('DONOTCACHEPAGE', true);\n    }\n\n    // use this hook to short-circuit the download logic\n    if (apply_filters('podlove_pre_media_file_download', false, $download_media_file)) {\n        exit;\n    }\n\n    $media_file_id = (int) $download_media_file;\n    $media_file = Model\\MediaFile::find_by_id($media_file_id);\n\n    if (!$media_file) {\n        status_header(404, 'Media File not found');\n        exit;\n    }\n\n    $episode_asset = $media_file->episode_asset();\n\n    if (!$episode_asset) {\n        status_header(404, 'Asset not found');\n        exit;\n    }\n\n    // if a file exists but no valid episode reference,\n    // that means it has been removed\n    $episode = $media_file->episode();\n\n    if (!$episode || !$episode->is_valid()) {\n        status_header(410, 'Gone');\n        exit;\n    }\n\n    do_action('podlove_download_file', $media_file);\n\n    // build redirect url\n    $location = $media_file->add_ptm_parameters(\n        $media_file->get_file_url(),\n        [\n            'source' => trim(podlove_get_query_var('ptm_source')),\n            'context' => trim(podlove_get_query_var('ptm_context')),\n            'request' => substr(md5(uniqid(microtime().wp_rand(), true)), 0, 12),\n        ]\n    );\n\n    header('HTTP/1.1 301 Moved Permanently');\n    header('Location: '.$location);\n    exit;\n}\n\n// add route for file downloads\nadd_action('init', function () {\n    add_rewrite_rule(\n        '^podlove/file/([0-9]+)/s/([^/]+)/c/([^/]+)/.+/?$',\n        'index.php?download_media_file=$matches[1]&ptm_source=$matches[2]&ptm_context=$matches[3]',\n        'top'\n    );\n    add_rewrite_rule(\n        '^podlove/file/([0-9]+)/s/([^/]+)/.+/?$',\n        'index.php?download_media_file=$matches[1]&ptm_source=$matches[2]',\n        'top'\n    );\n    add_rewrite_rule(\n        '^podlove/file/([0-9]+)/.+/?$',\n        'index.php?download_media_file=$matches[1]',\n        'top'\n    );\n}, 10);\n\nadd_filter('query_vars', function ($query_vars) {\n    $query_vars[] = 'download_media_file';\n    $query_vars[] = 'ptm_source';\n    $query_vars[] = 'ptm_context';\n\n    return $query_vars;\n}, 10, 1);\n\n// don't add trailing slash to file URLs\nadd_filter('redirect_canonical', function ($redirect_url, $requested_url) {\n    if ((int) get_query_var('download_media_file')) {\n        return false;\n    }\n\n    return $redirect_url;\n}, 10, 2);\n"
  },
  {
    "path": "includes/episode_number_column.php",
    "content": "<?php\n\nadd_filter('manage_edit-podcast_columns', 'podlove_add_episodeno_column_to_episodes_table');\nadd_action('manage_podcast_posts_custom_column', 'podlove_add_episodeno_column_content_to_episodes_table');\n\nfunction podlove_add_episodeno_column_to_episodes_table($columns)\n{\n    $keys = array_keys($columns);\n    $insertIndex = array_search('title', $keys); // before title column\n\n    // insert downloads at that index\n    return array_slice($columns, 0, $insertIndex, true)\n           + ['episode_number' => __('Ep. #', 'podlove-podcasting-plugin-for-wordpress')]\n           + array_slice($columns, $insertIndex, count($columns) - 1, true);\n}\n\nfunction podlove_add_episodeno_column_content_to_episodes_table($column_name)\n{\n    if ($column_name === 'episode_number') {\n        // check for null to prevent fatal error\n        if (\\Podlove\\get_episode() != null) {\n            echo \\Podlove\\get_episode()->number();\n        }\n    }\n}\n"
  },
  {
    "path": "includes/episode_number_quick_edit_form.php",
    "content": "<?php\n\nadd_action('quick_edit_custom_box', 'podlove_episodeno_quickedit_form');\nadd_action('save_post', 'podlove_episodeno_quickedit_save');\nadd_action('admin_footer', 'podlove_episodeno_quickedit_populate_form');\nadd_filter('post_row_actions', 'podlove_episodeno_quickedit_extend_action_items', 10, 2);\n\nfunction podlove_episodeno_quickedit_form($column_name)\n{\n    if ($column_name === 'episode_number') {\n        ?>\n\t\t\t<fieldset class=\"inline-edit-col-left\">\n\t\t\t\t<div class=\"inline-edit-col\">\n\t\t\t\t\t<label class=\"alignleft\">\n\t\t\t\t\t\t<span class=\"title\"><?php _e('Episode number', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t<input type=\"number\" min=\"0\" name=\"_podlove_meta[number]\" class=\"podlove_meta_quickedit_episode_number\" />\n\t\t\t\t\t</label>\n\t\t\t\t\t<?php wp_nonce_field('_podlove_meta_quickedit_episode_number', '_podlove_meta_quickedit_episode_number_nonce_field'); ?>\n\t\t\t</div>\n\t\t</fieldset>\n\t\t<?php\n    }\n}\n\nfunction podlove_episodeno_quickedit_save($post_id)\n{\n    if (!filter_input(INPUT_POST, '_podlove_meta_quickedit_episode_number_nonce_field')) {\n        return;\n    }\n\n    if (wp_verify_nonce($_POST['_podlove_meta_quickedit_episode_number_nonce_field'], '_podlove_meta_quickedit_episode_number')) {\n        $episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post_id);\n        $episode->number = sanitize_text_field($_POST['_podlove_meta']['number']);\n\n        if (is_object($episode) && is_string($episode->number)) {\n            $episode->save();\n        }\n    }\n}\n\nfunction podlove_episodeno_quickedit_populate_form()\n{\n    global $current_screen;\n\n    if ($current_screen->post_type !== 'podcast') {\n        return;\n    } ?>\n\t<script type=\"text/javascript\">\n\t\tfunction podlove_update_episode_number_quick_edit_form() {\n\t\t\tjQuery('button.editinline').on('click', function() {\n\t\t\t\tepisode_number = jQuery(this).data(\"podlove-update-quickedit-episode-number\");\n\n\t\t\t\tif (typeof episode_number === \"number\") {\n\t\t\t\t\tjQuery('.podlove_meta_quickedit_episode_number').val(episode_number);\n\t\t\t\t} else {\n\t\t\t\t\tjQuery('.podlove_meta_quickedit_episode_number').val('');\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tjQuery(document).ready(function() {\n\t\t\tjQuery('body').on('DOMNodeInserted', function() {\n\t\t\t\tpodlove_update_episode_number_quick_edit_form();\n\t\t\t});\n\n\t\t\tpodlove_update_episode_number_quick_edit_form();\n\t\t});\n\t</script>\n\t<?php\n}\n\nfunction podlove_episodeno_quickedit_extend_action_items($actions, $post)\n{\n    if ($post->post_type !== 'podcast') {\n        return $actions;\n    }\n\n    $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post->ID);\n    // Not nice but seems like there is no more elegant way to do this right now\n    if (isset($actions['inline hide-if-no-js'])) {\n        $actions['inline hide-if-no-js'] = str_replace('<button', '<button data-podlove-update-quickedit-episode-number=\"'.$episode->number.'\" ', $actions['inline hide-if-no-js']);\n    }\n\n    return $actions;\n}\n"
  },
  {
    "path": "includes/explicit_content.php",
    "content": "<?php\n\nadd_filter('podlove_episode_form_data', function ($form_data) {\n    if (!\\Podlove\\get_setting('metadata', 'enable_episode_explicit')) {\n        return $form_data;\n    }\n\n    $form_data[] = [\n        'type' => 'select',\n        'key' => 'explicit',\n        'options' => [\n            'label' => __('Explicit Content?', 'podlove-podcasting-plugin-for-wordpress'),\n            'type' => 'checkbox',\n            'html' => ['style' => 'width: 200px;'],\n            'default' => '-1',\n            'options' => [0 => 'false', 1 => 'true'],\n        ],\n        'position' => 770,\n    ];\n\n    return $form_data;\n});\n"
  },
  {
    "path": "includes/extras.php",
    "content": "<?php\n\n/**\n * Tiny behavior additions.\n *\n * Code should be moved into a separate file if:\n * - more than one hook is involved\n * - logic exceeds 10is lines\n */\n\n/*\n * Hackish workaround to flush rewrite rules.\n *\n * flush_rewrite_rules() is expensive, so it should only be called once.\n * However, calling it on activaton doesn't work. So I add a temporary flag\n * and call it when the flag exists. Transient is also used in other places\n * where rules must be rewritten.\n */\nadd_action('admin_init', function () {\n    if (delete_transient('podlove_needs_to_flush_rewrite_rules')) {\n        flush_rewrite_rules();\n    }\n}, 100);\n\n// initialize post type\nadd_action('init', function () {\n    new \\Podlove\\Podcast_Post_Type();\n    \\Podlove\\SlugFreeze::init();\n});\n\n// apply domain mapping plugin where it's essential\nadd_action('plugins_loaded', function () {\n    if (function_exists('domain_mapping_post_content')) {\n        add_filter('feed_link', 'domain_mapping_post_content', 20);\n        add_filter('podlove_subscribe_url', 'domain_mapping_post_content', 20);\n    }\n});\n\n/*\n * When changing from an external cover asset to 'manual', copy external url\n * into local field.\n */\nadd_filter('pre_update_option_podlove_asset_assignment', function ($new, $old) {\n    if (!isset($old['image']) || !isset($new['image'])) {\n        return $new;\n    }\n\n    if ($new['image'] != 'manual') {  // just changes to manual\n        return $new;\n    }\n\n    if (((int) $old['image']) <= 0) { // just changes from an asset\n        return $new;\n    }\n\n    \\Podlove\\Log::get()->addInfo('Copying cover art from asset to manual');\n\n    $episodes = \\Podlove\\Model\\Episode::find_all_by_time();\n\n    foreach ($episodes as $episode) {\n        if ($cover_art = $episode->cover_art()) {\n            $url = $cover_art->source_url();\n            \\Podlove\\Log::get()->addInfo('Copying cover art '.$url.' from asset to manual for episode '.$episode->id);\n            $episode->update_attribute('cover_art', $url);\n        }\n    }\n\n    return $new;\n}, 10, 2);\n"
  },
  {
    "path": "includes/feed_discovery.php",
    "content": "<?php\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Feeds;\nuse Podlove\\Model;\n\n/**\n * Adds feed discover links to WordPress head.\n */\nfunction podlove_add_feed_discoverability()\n{\n    if (is_admin()) {\n        return;\n    }\n\n    if (!function_exists('\\Podlove\\Feeds\\prepare_for_feed')) {\n        require_once \\Podlove\\PLUGIN_DIR.'lib/feeds/base.php';\n    }\n\n    // we need separate caches for http and https\n    $cache_key = 'feed_discoverability_'.(int) is_ssl();\n    echo TemplateCache::get_instance()->cache_for($cache_key, function () {\n        $feeds = Model\\Podcast::get()->feeds();\n\n        // only discoverable feeds\n        $feeds = array_filter($feeds, function ($feed) {\n            return $feed->discoverable;\n        });\n\n        $links = array_map(function ($feed) {\n            return '<link rel=\"alternate\" type=\"'.$feed->get_content_type().'\" title=\"'.Feeds\\prepare_for_feed($feed->title_for_discovery()).'\" href=\"'.$feed->get_subscribe_url().\"\\\" />\\n\";\n        }, $feeds);\n\n        return \"\\n\".implode('', $links);\n    });\n}\n\nadd_action('init', function () {\n    // priority 2 so they are placed below the WordPress default discovery links\n    add_action('wp_head', 'podlove_add_feed_discoverability', 2);\n\n    // hide WordPress default link discovery\n    if (\\Podlove\\get_setting('website', 'hide_wp_feed_discovery') === 'on') {\n        remove_action('wp_head', 'feed_links', 2);\n        remove_action('wp_head', 'feed_links_extra', 3);\n    }\n});\n"
  },
  {
    "path": "includes/frontend_styles.php",
    "content": "<?php\n\n/**\n * register frontend styles.\n */\nadd_action('init', function () {\n    if (is_admin()) {\n        return;\n    }\n\n    wp_register_style(\n        'podlove-frontend-css',\n        \\Podlove\\PLUGIN_URL.'/css/frontend.css',\n        [],\n        '1.0'\n    );\n    wp_enqueue_style('podlove-frontend-css');\n\n    wp_register_style('podlove-admin-font', \\Podlove\\PLUGIN_URL.'/css/admin-font.css', [], \\Podlove\\get_plugin_header('Version'));\n    wp_enqueue_style('podlove-admin-font');\n});\n"
  },
  {
    "path": "includes/http.php",
    "content": "<?php\n\n/**\n * Checks if the http status code is resolved and counts as \"reachable\".\n *\n * Returns true for 2\\d\\d and 304.\n *\n * @param int $status\n *\n * @return bool\n */\nfunction podlove_is_resolved_and_reachable_http_status($status)\n{\n    return $status >= 200 && $status < 300 || $status == 304;\n}\n"
  },
  {
    "path": "includes/images.php",
    "content": "<?php\n\nuse Podlove\\Cache\\HttpHeaderValidator;\nuse Podlove\\Log;\nuse Podlove\\Model\\Image;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n// WP Cron: Image cache validation\nadd_action('wp', function () {\n    if (!wp_next_scheduled('podlove_validate_image_cache')) {\n        wp_schedule_event(time(), 'daily', 'podlove_validate_image_cache');\n    }\n});\n\nadd_action('podlove_validate_image_cache', 'podlove_validate_image_cache');\nadd_action('podlove_refetch_cached_image', 'podlove_refetch_cached_image', 10, 2);\n\nfunction podlove_validate_image_cache()\n{\n    set_time_limit(5 * MINUTE_IN_SECONDS);\n\n    $start_time = hrtime(true);\n    $cache_files = glob(trailingslashit(Image::cache_dir()).'*'.DIRECTORY_SEPARATOR.'*'.DIRECTORY_SEPARATOR.'cache.yml');\n    foreach ($cache_files as $cache_file) {\n        $cache = Yaml::parse(file_get_contents($cache_file));\n\n        if (!isset($cache['etag'])) {\n            $cache['etag'] = null;\n        }\n\n        if (!isset($cache['last-modified'])) {\n            $cache['last-modified'] = null;\n        }\n\n        $validator = new HttpHeaderValidator($cache['source'], $cache['etag'], $cache['last-modified']);\n        $validator->validate();\n        if ($validator->hasChanged()) {\n            wp_schedule_single_event(time(), 'podlove_refetch_cached_image', [$cache['source'], $cache['filename']]);\n        }\n    }\n\n    $stop_time = hrtime(true);\n    $duration = ($stop_time - $start_time) / 1e+6;\n    $duration_string = round($duration).'ms';\n    \\Podlove\\Log::get()->addInfo(sprintf('Finished validating %d images in %s', count($cache_files), $duration_string));\n}\n\nfunction podlove_refetch_cached_image($url, $filename)\n{\n    (new Image($url, $filename))->redownload_source();\n}\n\n// add routes\nadd_action('init', function () {\n    add_rewrite_rule(\n        '^podlove/image/([^/]+)/([0-9]+)/([0-9]+)/([0-9])/([^/]+)/?$',\n        'index.php?podlove_image_cache_url=$matches[1]&podlove_width=$matches[2]&podlove_height=$matches[3]&podlove_crop=$matches[4]&podlove_file_name=$matches[5]',\n        'top'\n    );\n}, 10);\n\nadd_filter('query_vars', function ($query_vars) {\n    $query_vars[] = 'podlove_image_cache_url';\n    $query_vars[] = 'podlove_width';\n    $query_vars[] = 'podlove_height';\n    $query_vars[] = 'podlove_crop';\n    $query_vars[] = 'podlove_file_name';\n\n    return $query_vars;\n}, 10, 1);\n\nadd_action('wp', 'podlove_handle_cache_files');\n\nfunction podlove_handle_cache_files()\n{\n    $source_url = \\Podlove\\PHP\\hex2str(podlove_get_query_var('podlove_image_cache_url'));\n    $file_name = urldecode(podlove_get_query_var('podlove_file_name'));\n    $width = (int) podlove_get_query_var('podlove_width');\n    $height = (int) podlove_get_query_var('podlove_height');\n    $crop = (bool) podlove_get_query_var('podlove_crop');\n\n    if (!$source_url) {\n        return;\n    }\n\n    // Tell WP Super Cache to not cache download links\n    if (!defined('DONOTCACHEPAGE')) {\n        define('DONOTCACHEPAGE', true);\n    }\n\n    $image = new Image($source_url, $file_name);\n\n    if (!$image->source_exists()) {\n        $image->download_source();\n    }\n\n    // Bail if download fails\n    if (!$image->source_exists()) {\n        Log::get()->error('Download failed for image: '.$image->url());\n        status_header(307);\n        header('Location: '.$source_url);\n        exit;\n    }\n\n    $imageinfo = getimagesize($image->original_file());\n\n    // Bail if we cannot determine image meta\n    if ($imageinfo === false) {\n        Log::get()->error('Image size cannot be determined for file: '.$image->original_file());\n        status_header(307);\n        header('Location: '.$source_url);\n        exit;\n    }\n\n    list($orig_width, $orig_height) = $imageinfo;\n\n    // Do not try to enlarge images\n    if ($width > $orig_width) {\n        $width = $orig_width;\n    }\n\n    if ($height > $orig_height) {\n        $height = $orig_height;\n    }\n\n    $image\n        ->setWidth($width)\n        ->setHeight($height)\n        ->setCrop($crop)\n    ;\n\n    if (!file_exists($image->resized_file())) {\n        $image->generate_resized_copy();\n    }\n\n    $file = $image->resized_file();\n\n    // Bail if resize fails\n    if (!file_exists($file)) {\n        Log::get()->error('Image resize failed for file: '.$file);\n        status_header(307);\n        header('Location: '.$source_url);\n        exit;\n    }\n\n    $imageInfo = getimagesize($file);\n    switch ($imageInfo[2]) {\n        case IMAGETYPE_JPEG:\n            header('Content-Type: image/jpeg');\n\n            break;\n        case IMAGETYPE_GIF:\n            header('Content-Type: image/gif');\n\n            break;\n        case IMAGETYPE_PNG:\n            header('Content-Type: image/png');\n\n            break;\n\n        default:\n            Log::get()->error('Unsupported image type for file: '.$file);\n\n            return;\n    }\n\n    header('Content-Length: '.filesize($file));\n    header('Cache-Control: public, max-age=86400');\n    header('Expires: '.gmdate('D, d M Y H:i:s \\G\\M\\T', time() + 86400));\n\n    $time = filemtime($file);\n    $etag = md5($time.$source_url);\n    $last_modified = gmdate('D, d M Y H:i:s \\G\\M\\T', $time);\n\n    $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ?? false;\n    $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ?? false;\n\n    if ((($if_none_match && $if_none_match == $etag) || (!$if_none_match))\n        && ($if_modified_since && $if_modified_since == $last_modified)) {\n        header('HTTP/1.1 304 Not Modified');\n    } else {\n        header(\"Last-Modified: {$last_modified}\");\n        header(\"ETag: {$etag}\");\n\n        readfile($file);\n    }\n    exit;\n}\n"
  },
  {
    "path": "includes/import.php",
    "content": "<?php\n\n// Fix WordPress post meta import for our custom GUID\n// The importer inserts a post, which creates a new GUID. Then it adds the\n// post metas resulting in two GUID entries. Here we make sure to only use\n// the previous one.\nadd_action('added_post_meta', function ($meta_id, $post_id, $meta_key, $_meta_value) {\n    if ($meta_key !== '_podlove_guid') {\n        return;\n    }\n\n    $metas = get_post_meta($post_id, '_podlove_guid');\n    if (count($metas) > 1) {\n        foreach ($metas as $meta) {\n            if ($meta !== $_meta_value) {\n                delete_post_meta($post_id, $meta_key, $meta);\n            }\n        }\n    }\n}, 10, 4);\n\n// Ensure WordPress importer keeps the mapping id for old<->new post id.\n// This is required for the Im/Export module. To avoid user errors, it is\n// better to keep this behaviour in core.\nadd_filter('wp_import_post_meta', function ($postmetas, $post_id, $post) {\n    $postmetas[] = [\n        'key' => 'import_id',\n        'value' => $post_id,\n    ];\n\n    return $postmetas;\n}, 10, 3);\n"
  },
  {
    "path": "includes/jetpack.php",
    "content": "<?php\n\n// enable jetpack's publicize for our podcast post type\nadd_action('init', 'podlove_jetpack_enable_publicize');\n\nfunction podlove_jetpack_enable_publicize()\n{\n    add_post_type_support('podcast', 'publicize');\n}\n\n// remove jetpack rss icon from podcast feeds which conflicts with our own rss icon\nadd_action('template_redirect', 'podlove_jetpack_remove_rss_icon', 11);\n\nfunction podlove_jetpack_remove_rss_icon()\n{\n    if (!method_exists('Jetpack_Site_Icon', 'init')) {\n        return;\n    }\n\n    if (!$feed_slug = get_query_var('feed')) {\n        return;\n    }\n\n    if (!$feed = \\Podlove\\Model\\Feed::find_one_by_slug($feed_slug)) {\n        return;\n    }\n\n    remove_action('rss2_head', [Jetpack_Site_Icon::init(), 'rss2_icon']);\n}\n"
  },
  {
    "path": "includes/license.php",
    "content": "<?php\nuse Podlove\\Model;\n\nif (\\Podlove\\get_setting('metadata', 'enable_episode_license')) {\n    add_filter('podlove_episode_form_data', 'podlove_episode_license_extend_form', 10, 2);\n}\n\nfunction podlove_episode_license_extend_form($form_data, $episode)\n{\n    $podcast = Model\\Podcast::get();\n    $license = $episode->get_license();\n\n    $form_data[] = [\n        'type' => 'callback',\n        'key' => 'podlove_cc_license_selector',\n        'options' => [\n            'label' => '',\n            'callback' => function () {\n                ?>\n                <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n                    <podlove-license></podlove-license>\n                </div>\n                <?php\n            },\n        ],\n        'position' => 522,\n    ];\n\n    return $form_data;\n}\n"
  },
  {
    "path": "includes/merge_episodes.php",
    "content": "<?php\n\n/**\n * Handle \"Merge Episodes\" setting.\n */\n\n// Checking \"merge_episodes\" allows to see episodes on the front page.\nadd_action('pre_get_posts', function ($wp_query) {\n    if (\\Podlove\\get_setting('website', 'merge_episodes') !== 'on') {\n        return;\n    }\n\n    if (is_home() && $wp_query->is_main_query() && !isset($wp_query->query_vars['post_type'])) {\n        $wp_query->set(\n            'post_type',\n            array_merge(['post', 'podcast'], (array) $wp_query->get('post_type'))\n        );\n    }\n});\n\n// Checking \"merge_episodes\" also includes episodes in main feed\nadd_filter('request', function ($query_var) {\n    if (!isset($query_var['feed'])) {\n        return $query_var;\n    }\n\n    if (\\Podlove\\get_setting('website', 'merge_episodes') !== 'on') {\n        return $query_var;\n    }\n\n    $extend = [\n        'post' => 'post',\n        'podcast' => 'podcast',\n    ];\n\n    if (empty($query_var['post_type']) || !is_array($query_var['post_type'])) {\n        $query_var['post_type'] = $extend;\n    } else {\n        $query_var['post_type'] = array_merge($query_var['post_type'], $extend);\n    }\n\n    return $query_var;\n});\n"
  },
  {
    "path": "includes/modules.php",
    "content": "<?php\n/**\n * Register Publisher Modules.\n */\n\nuse Podlove\\Log;\nuse Podlove\\Modules;\n\n// init modules\nadd_action('plugins_loaded', function () {\n    $modules = Modules\\Base::get_active_module_names();\n\n    if (empty($modules)) {\n        return;\n    }\n\n    foreach ($modules as $module_name) {\n        $class = Modules\\Base::get_class_by_module_name($module_name);\n        if (class_exists($class)) {\n            $class::instance()->load();\n        } else {\n            Modules\\Base::deactivate($module_name);\n            add_action('admin_notices', function () use ($module_name) {\n                ?>\n\t\t\t\t<div id=\"message\" class=\"error\">\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<strong><?php echo __('Warning'); ?></strong>\n\t\t\t\t\t\t<?php echo sprintf(__('Podlove Module \"%s\" could not be found and has been deactivated.', 'podlove-podcasting-plugin-for-wordpress'), $module_name); ?>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            });\n        }\n    }\n});\n\n// Add core modules to \"activated modules\" to ensure:\n// 1. they are active\n// 2. activation hook gets fired\nadd_filter('pre_update_option_podlove_active_modules', function ($new_val, $old_val) {\n    // bring in form\n    $core_modules = [];\n    foreach (Modules\\Base::get_core_module_names() as $module) {\n        $core_modules[$module] = 'on';\n    }\n\n    return array_merge($new_val, $core_modules);\n}, 10, 2);\n\n// fire activation and deactivation hooks for modules\nadd_action('update_option_podlove_active_modules', function ($old_val, $new_val) {\n    $deactivated_modules = array_keys(array_diff_assoc($old_val, $new_val));\n    $activated_modules = array_keys(array_diff_assoc($new_val, $old_val));\n\n    if ($deactivated_modules) {\n        foreach ($deactivated_modules as $deactivated_module) {\n            Log::get()->addInfo('Deactivate module \"'.$deactivated_module.'\"');\n            do_action('podlove_module_was_deactivated', $deactivated_module);\n            do_action('podlove_module_was_deactivated_'.$deactivated_module);\n        }\n    }\n\n    if ($activated_modules) {\n        foreach ($activated_modules as $activated_module) {\n            Log::get()->addInfo('Activate module \"'.$activated_module.'\"');\n\n            // init module before firing hooks\n            $class = Modules\\Base::get_class_by_module_name($activated_module);\n            if (class_exists($class)) {\n                $class::instance()->load();\n            }\n\n            do_action('podlove_module_was_activated', $activated_module);\n            do_action('podlove_module_was_activated_'.$activated_module);\n        }\n    }\n}, 10, 2);\n"
  },
  {
    "path": "includes/no_enclosure_autodiscovery.php",
    "content": "<?php\n\n/**\n * Don't autodiscover enclosures in posts.\n *\n * WordPress tries to find enclosures in posts. It happens for all posts with a\n * meta entry \"_encloseme\". Solution: Delete that entry once it's created.\n *\n * @param int    $meta_id\n * @param int    $post_id\n * @param string $meta_key\n * @param mixed  $meta_value\n */\nfunction podlove_no_enclosure_autodiscovery($meta_id, $post_id, $meta_key, $meta_value)\n{\n    global $wpdb;\n\n    if ($meta_key != '_encloseme') {\n        return;\n    }\n\n    $sql = \"\n\t\tDELETE FROM\n\t\t\t{$wpdb->postmeta} \n\t\tWHERE\n\t\t\tpost_id = '{$post_id}'\n\t\t\tAND meta_key = '_encloseme'\n\t\t\tAND meta_id = '{$meta_id}'\n\t\t\";\n\n    $wpdb->query($sql);\n}\nadd_action('added_post_meta', 'podlove_no_enclosure_autodiscovery', 10, 4);\n// legacy support\nadd_action('added_postmeta', 'podlove_no_enclosure_autodiscovery', 10, 4);\n"
  },
  {
    "path": "includes/permalinks.php",
    "content": "<?php\n\nif (get_option('permalink_structure') != '') {\n    add_action('after_setup_theme', 'podlove_add_podcast_rewrite_rules', 99);\n    add_action('permalink_structure_changed', 'podlove_add_podcast_rewrite_rules');\n    add_action('wp', 'podlove_no_verbose_page_rules');\n    add_filter('post_type_link', 'podlove_generate_custom_post_link', 10, 4);\n    add_filter('post_rewrite_rules', 'podlove_add_podcast_episode_rules_to_post_rules');\n\n    if (podlove_and_wordpress_permastructs_are_equal()) {\n        add_filter('request', 'podlove_podcast_permalink_proxy');\n    }\n}\n\nfunction podlove_and_wordpress_permastructs_are_equal()\n{\n    if (\\Podlove\\get_setting('website', 'use_post_permastruct') == 'on') {\n        return true;\n    }\n\n    return untrailingslashit(\\Podlove\\get_setting('website', 'custom_episode_slug')) == untrailingslashit(str_replace('%postname%', '%podcast%', get_option('permalink_structure')));\n}\n\n/**\n * Changes the permalink for a custom post type.\n *\n * @uses $wp_rewrite\n */\nfunction podlove_add_podcast_rewrite_rules()\n{\n    global $wp_rewrite;\n\n    // Get permalink structure\n    $permastruct = \\Podlove\\get_setting('website', 'custom_episode_slug');\n\n    // Add rewrite tag\n    $wp_rewrite->add_rewrite_tag('%podcast%', '([^/]+)', 'post_type=podcast&name=');\n\n    // Use same permastruct as post_type 'post'\n    if (podlove_and_wordpress_permastructs_are_equal()) {\n        $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure'));\n    }\n\n    // Enable generic rules for pages if permalink structure doesn't begin with a wildcard\n    if ('/%podcast%' == untrailingslashit($permastruct)) {\n        // Generate custom rewrite rules\n        $wp_rewrite->matches = 'matches';\n        $wp_rewrite->extra_rules = array_merge(\n            $wp_rewrite->extra_rules,\n            $wp_rewrite->generate_rewrite_rules('%podcast%', EP_PERMALINK, true, true, false, true, true)\n        );\n        $wp_rewrite->matches = '';\n\n        // Add for WP_Query\n        $wp_rewrite->use_verbose_page_rules = true;\n    }\n\n    // Add archive pages\n    //\n    // set the constant `define('PODLOVE_ARCHIVE_PAGES', true)` to enable this.\n    // I removed this once because it broke stuff, see https://github.com/podlove/podlove-publisher/commit/b4d9f148ecb5fc82520a775cc38a77ec505aeb3a#diff-0533ec9c53ef1127dfc1a79fa5c24199\n    // However it's a feature still in use, so I at least want to give the choice to enable it via constant\n    // see https: //github.com/podlove/podlove-publisher/issues/978\n    if ('on' == \\Podlove\\get_setting('website', 'episode_archive') && defined('PODLOVE_ARCHIVE_PAGES') && PODLOVE_ARCHIVE_PAGES) {\n        $archive_slug = trim(\\Podlove\\get_setting('website', 'episode_archive_slug'), '/');\n        $blog_prefix = \\Podlove\\get_blog_prefix();\n        $blog_prefix = $blog_prefix ? trim($blog_prefix, '/').'/' : '';\n        $wp_rewrite->add_rule(\"{$blog_prefix}{$archive_slug}/?$\", 'index.php?post_type=podcast', 'top');\n        $wp_rewrite->add_rule(\"{$blog_prefix}{$archive_slug}/{$wp_rewrite->pagination_base}/([0-9]{1,})/?$\", 'index.php?post_type=podcast&paged=$matches[1]', 'top');\n    }\n}\n\n/**\n * Add podcast episode rules to post rules.\n *\n * Add to post rewrite rules our rules for a podcast episode to respect correct\n * rule order. Needed to not interfere with other rules (like feeds).\n *\n * @since 1.10.17\n *\n * @param array $post_rewrite the rewrite rules for posts\n *\n * @return array an associate array of matches and queries\n */\nfunction podlove_add_podcast_episode_rules_to_post_rules($post_rewrite)\n{\n    global $wp_rewrite;\n\n    // Get permalink structure\n    $permastruct = \\Podlove\\get_setting('website', 'custom_episode_slug');\n\n    // Use same permastruct as post_type 'post'\n    if (podlove_and_wordpress_permastructs_are_equal()) {\n        $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure'));\n    }\n\n    // Don't add rules here, if use the other method\n    // @see \\Podlove\\add_podcast_rewrite_rules\n    if ('/%podcast%' == untrailingslashit($permastruct)) {\n        return $post_rewrite;\n    }\n\n    // Generate rules for podcast episode and merge them with post rules\n    $post_rewrite = array_merge($wp_rewrite->generate_rewrite_rules($permastruct, EP_PERMALINK, true, true, false, true, true), $post_rewrite);\n\n    return $post_rewrite;\n}\n\n/**\n * Filters the request query vars to search for posts with type 'post' and 'podcast'.\n *\n * @param mixed $query_vars\n */\nfunction podlove_podcast_permalink_proxy($query_vars)\n{\n    global $wpdb;\n\n    // Previews default to post type \"post\" which is unfortunate.\n    // However, when there is a name, we can determine the post_type anyway.\n    // I don't think this is 100% bulletproof but seems to work well enough.\n    if (isset($query_vars['preview']) && !isset($query_vars['post_type']) && isset($query_vars['name'])) {\n        $query_vars['post_type'] = $wpdb->get_var(\n            $wpdb->prepare('SELECT post_type FROM '.$wpdb->posts.' WHERE post_name = %s', $query_vars['name'])\n        );\n    }\n\n    // No post request\n    if (isset($query_vars['preview']) || false === (isset($query_vars['name']) || isset($query_vars['p']))) {\n        return $query_vars;\n    }\n\n    if (!isset($query_vars['post_type']) || $query_vars['post_type'] == 'post') {\n        $query_vars['post_type'] = ['podcast', 'post'];\n    }\n\n    return $query_vars;\n}\n\n/**\n * Disable verbose page rules mode after startup.\n *\n * @uses $wp_rewrite\n */\nfunction podlove_no_verbose_page_rules()\n{\n    global $wp_rewrite;\n    $wp_rewrite->use_verbose_page_rules = false;\n}\n\n/**\n * Replace placeholders in permalinks with the correct values.\n *\n * @param mixed $post_link\n * @param mixed $id\n * @param mixed $leavename\n * @param mixed $sample\n */\nfunction podlove_generate_custom_post_link($post_link, $id, $leavename = false, $sample = false)\n{\n    // Get post\n    $post = get_post($id);\n\n    // Generate urls only for podcast episodes\n    if ('podcast' != $post->post_type) {\n        return $post_link;\n    }\n\n    // Draft or pending?\n    $draft_or_pending = isset($post->post_status) && in_array($post->post_status, ['draft', 'pending', 'auto-draft']);\n\n    // Sample\n    if ($sample && true == $leavename) {\n        $post->post_name = '%pagename%';\n    }\n\n    // Get permastruct\n    $permastruct = \\Podlove\\get_setting('website', 'custom_episode_slug');\n\n    if (podlove_and_wordpress_permastructs_are_equal()) {\n        $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure'));\n    }\n\n    // Only post_name in URL\n    if ('/%podcast%' == untrailingslashit($permastruct) && (!$draft_or_pending || $sample)) {\n        return home_url(user_trailingslashit($post->post_name));\n    }\n\n    // Generate post link\n    if (!$draft_or_pending || $sample) {\n        $post_link = home_url(user_trailingslashit($permastruct));\n    }\n\n    // Replace simple placeholders\n    $unixtime = strtotime($post->post_date);\n    $post_link = str_replace('%year%', date('Y', $unixtime), $post_link);\n    $post_link = str_replace('%monthnum%', date('m', $unixtime), $post_link);\n    $post_link = str_replace('%day%', date('d', $unixtime), $post_link);\n    $post_link = str_replace('%hour%', date('H', $unixtime), $post_link);\n    $post_link = str_replace('%minute%', date('i', $unixtime), $post_link);\n    $post_link = str_replace('%second%', date('s', $unixtime), $post_link);\n    $post_link = str_replace('%post_id%', $post->ID, $post_link);\n    $post_link = str_replace('%podcast%', $post->post_name, $post_link);\n\n    // category and author replacement copied from WordPress core\n    if (false !== strpos($permastruct, '%category%')) {\n        $cats = get_the_category($post->ID);\n\n        if ($cats) {\n            if (function_exists('wp_list_sort')) {\n                $cats = wp_list_sort($cats, 'term_id', 'ASC');\n            } else {\n                usort($cats, '_usort_terms_by_ID');\n            }\n\n            $category_object = apply_filters('post_link_category', $cats[0], $cats, $post);\n            $category_object = get_term($category_object, 'category');\n            $category = $category_object->slug;\n\n            if ($parent = $category_object->parent) {\n                $category = get_category_parents($parent, false, '/', true).$category;\n            }\n        }\n\n        if (empty($category)) {\n            $default_category = get_category(get_option('default_category'));\n            $category = is_wp_error($default_category) ? '' : $default_category->slug;\n        }\n\n        $post_link = str_replace('%category%', $category, $post_link);\n    }\n\n    if (false !== strpos($permastruct, '%author%')) {\n        $authordata = get_userdata($post->post_author);\n        $post_link = str_replace('%author%', $authordata->user_nicename, $post_link);\n    }\n\n    return $post_link;\n}\n"
  },
  {
    "path": "includes/podlove-web-player-5.php",
    "content": "<?php\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\EpisodeAsset;\nuse Podlove\\Model\\FileType;\nuse Podlove\\Model\\MediaFile;\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\nuse Podlove\\Modules\\PodloveWebPlayer\\PlayerV3\\PlayerMediaFiles;\n\nfunction podlove_pwp5_init()\n{\n    add_filter('podlove_web_player_shortcode_episode_attributes', 'podlove_pwp5_attributes');\n}\n\nfunction podlove_pwp5_attributes($attributes)\n{\n    $post_id = (isset($attributes['post_id']) && $attributes['post_id']) ? $attributes['post_id'] : get_the_ID();\n    $episode = Episode::find_one_by_post_id($post_id);\n\n    if (!$episode) {\n        return [];\n    }\n\n    $post = get_post($episode->post_id);\n    $podcast = Podcast::get();\n\n    $chapters = array_map(function ($c) {\n        $c->title = html_entity_decode(trim($c->title));\n\n        return $c;\n    }, (array) json_decode($episode->get_chapters('json')));\n\n    $config = [\n        'version' => 5,\n        'show' => [\n            'title' => $podcast->title ?? '',\n            'subtitle' => $podcast->subtitle ?? '',\n            'summary' => $podcast->summary ?? '',\n            'poster' => $podcast->cover_art()->setWidth(500)->url() ?? '',\n            'link' => \\Podlove\\get_landing_page_url() ?? '',\n        ],\n        'title' => $post->post_title ?? '',\n        'subtitle' => trim($episode->subtitle ?? ''),\n        'summary' => trim($episode->summary ?? ''),\n        'publicationDate' => mysql2date('c', $post->post_date),\n        'duration' => $episode->get_duration('full'),\n        'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(),\n        'link' => get_permalink($episode->post_id),\n        'chapters' => $chapters ? $chapters : [],\n        'audio' => podlove_pwp5_audio_files($episode, null),\n        'files' => podlove_pwp5_files($episode, null),\n    ];\n\n    if (\\Podlove\\Modules\\Base::is_active('contributors')) {\n        $config['contributors'] = array_values(array_filter(array_map(function ($c) {\n            $contributor = $c->getContributor();\n\n            if (!$contributor || !$contributor->visibility) {\n                return [];\n            }\n\n            return [\n                'id' => $contributor->id,\n                'name' => $contributor->getName(),\n                'avatar' => $contributor->avatar()->setWidth(150)->setHeight(150)->url(),\n                'role' => $c->hasRole() ? $c->getRole()->to_array() : null,\n                'group' => $c->hasGroup() ? $c->getGroup()->to_array() : null,\n                'comment' => $c->comment,\n            ];\n        }, EpisodeContribution::find_all_by_episode_id($episode->id))));\n    }\n\n    return apply_filters('podlove_player5_config', $config, $episode);\n}\n\nfunction podlove_pwp5_audio_files($episode, $context)\n{\n    $player_media_files = new PlayerMediaFiles($episode);\n\n    if ($media_files = $player_media_files->get($context)) {\n        $media_file_urls = array_map(function ($file) {\n            return [\n                'url' => $file['publicUrl'],\n                'size' => $file['size'],\n                'title' => $file['assetTitle'],\n                'mimeType' => $file['mime_type'],\n            ];\n        }, $media_files);\n    } elseif (is_admin()) {\n        $media_file_urls = [\n            'src' => \\Podlove\\PLUGIN_URL.'/bin/podlove.mp3',\n            'size' => 486839,\n            'title' => 'Podlove Example Audio',\n            'mimeType' => 'audio/mp3',\n        ];\n    } else {\n        $media_file_urls = [];\n    }\n\n    return $media_file_urls;\n}\n\nfunction podlove_pwp5_files($episode, $context)\n{\n    global $wpdb;\n\n    $sql = 'SELECT\n    mf.id media_file_id, mf.size file_size, a.title asset_tile, a.downloadable, a.`position`, ft.mime_type, ft.`extension`\nFROM\n    '.Episode::table_name().' e\n    LEFT JOIN '.MediaFile::table_name().' mf ON mf.episode_id = e.id AND mf.active = 1\n    LEFT JOIN '.EpisodeAsset::table_name().' a ON a.id = mf.episode_asset_id\n    LEFT JOIN '.FileType::table_name().' ft ON ft.id = a.file_type_id\nWHERE\n    e.id = %d AND a.downloadable AND mf.active\nORDER BY\n    position ASC\n    ';\n\n    $files = $wpdb->get_results($wpdb->prepare($sql, $episode->id), ARRAY_A);\n\n    return array_map(function ($row) use ($context) {\n        $media_file = MediaFile::find_by_id($row['media_file_id']);\n\n        return [\n            'url' => $media_file->get_public_file_url('webplayer', $context),\n            'size' => $row['file_size'],\n            'title' => $row['asset_tile'],\n            'mimeType' => $row['mime_type'],\n        ];\n    }, $files);\n}\n\npodlove_pwp5_init();\n"
  },
  {
    "path": "includes/podlove_data_js_adapter.php",
    "content": "<?php\n\n/**\n * Add data to the window.PODLOVE_DATA interface using the podlove_data_js hook.\n *\n * Example:\n *\n *     add_filter('podlove_data_js', function ($data) {\n *         $data['my_module_name'] = ['my' => 'module values'];\n *         return $data;\n *     });\n */\nadd_action('admin_head', 'podlove_init_js_adapter', 3);\n\nadd_filter('podlove_data_js', 'podlove_js_adapter_inject_settings');\n\nfunction podlove_init_js_adapter()\n{\n    ?>\n    <script>\n    <?php podlove_init_js_content(); ?>\n    window.addEventListener('load', function () {\n      if (window.initPodloveUI) {\n        window.initPodloveUI(window.PODLOVE_DATA);\n      }\n    })\n    </script>\n    <?php\n}\n\nfunction podlove_init_js_content()\n{\n    $data = apply_filters('podlove_data_js', []); ?>\n\n    window.PODLOVE_DATA = window.PODLOVE_DATA || { baseUrl: '<?php echo home_url(); ?>' };\n    <?php foreach ($data as $key => $value) { ?>\n        window.PODLOVE_DATA['<?php echo $key; ?>'] = <?php echo wp_json_encode($value); ?>;\n    <?php } ?>\n<?php\n}\n\n// in development mode, allow a client to fetch the JS hook\n// we use this in client/index.html\nadd_action('init', function () {\n    if (!WP_Site_Health::get_instance()->is_development_environment()) {\n        return;\n    }\n\n    if (isset($_GET['hook']) && $_GET['hook'] === 'podlove-js-hook') {\n        // add CORS headers to allow anything\n        header('Access-Control-Allow-Origin: *');\n        header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n        header('Access-Control-Allow-Headers: Content-Type');\n\n        podlove_init_js_content();\n        exit;\n    }\n});\n\nfunction podlove_js_adapter_inject_settings($data)\n{\n    $defaults = \\Podlove\\get_setting_defaults();\n    $podcast = \\Podlove\\Model\\Podcast::get();\n\n    $settings_tab_names = ['website', 'metadata', 'tracking'];\n\n    $data['expert_settings'] = array_reduce($settings_tab_names, function ($tabs, $tab_name) use ($defaults) {\n        $tabs[$tab_name] = array_reduce(array_keys($defaults[$tab_name]), function ($settings, $setting_name) use ($tab_name) {\n            $settings[$setting_name] = \\Podlove\\get_setting($tab_name, $setting_name);\n\n            return $settings;\n        }, []);\n\n        return $tabs;\n    }, []);\n\n    $data['media'] = ['base_uri' => $podcast->get_media_file_base_uri()];\n    $data['modules'] = \\Podlove\\Modules\\Base::get_active_module_names();\n\n    return $data;\n}\n"
  },
  {
    "path": "includes/recording_date.php",
    "content": "<?php\n\nadd_filter('podlove_episode_form_data', function ($form_data) {\n    if (!\\Podlove\\get_setting('metadata', 'enable_episode_recording_date')) {\n        return $form_data;\n    }\n\n    $form_data[] = [\n        'type' => 'string',\n        'key' => 'recording_date',\n        'options' => [\n            'label' => __('Recording Date', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '',\n            'html' => ['class' => 'regular-text podlove-check-input'],\n        ],\n        'position' => 750,\n    ];\n\n    return $form_data;\n});\n\nadd_filter('podlove_episode_data_filter', function ($filter) {\n    return array_merge($filter, [\n        'recording_date' => FILTER_UNSAFE_RAW,\n    ]);\n});\n"
  },
  {
    "path": "includes/redirects.php",
    "content": "<?php\n\nadd_filter('template_redirect', 'podlove_handle_user_redirects');\nadd_filter('template_redirect', 'podlove_handle_episode_redirects');\n\n/**\n * Handle configured redirects.\n */\nfunction podlove_handle_user_redirects()\n{\n    global $wpdb, $wp_query;\n\n    if (is_admin()) {\n        return;\n    }\n\n    // check for global redirects\n    $parsed_request = wp_parse_url($_SERVER['REQUEST_URI']);\n    $parsed_request_url = $parsed_request['path'];\n    if (isset($parsed_request['query'])) {\n        $parsed_request_url .= '?'.$parsed_request['query'];\n    }\n\n    $redirects = \\Podlove\\get_setting('redirects', 'podlove_setting_redirect');\n\n    if (!is_array($redirects)) {\n        return;\n    }\n\n    foreach ($redirects as $index => $redirect) {\n        if (!isset($redirect['active'])) {\n            continue;\n        }\n\n        if (!strlen(trim($redirect['from'])) || !strlen(trim($redirect['to']))) {\n            continue;\n        }\n\n        $parsed_url = wp_parse_url($redirect['from']);\n        $parsed_redirect_url = $parsed_url['path'];\n\n        if (isset($parsed_url['query'])) {\n            $parsed_redirect_url .= '?'.$parsed_url['query'];\n        }\n\n        if (untrailingslashit($parsed_redirect_url) === untrailingslashit($parsed_request_url)) {\n            if ($redirect['code']) {\n                $http_code = (int) $redirect['code'];\n            } else {\n                $http_code = 301; // default to permanent\n            }\n\n            // fallback for HTTP/1.0 clients\n            if ($http_code == 307 && $_SERVER['SERVER_PROTOCOL'] == 'HTTP/1.0') {\n                $http_code = 302;\n            }\n\n            // increment redirection counter\n            ++$redirects[$index]['count'];\n            \\Podlove\\save_setting('redirects', 'podlove_setting_redirect', $redirects);\n\n            // redirect\n            status_header($http_code);\n            $wp_query->is_404 = false;\n            wp_redirect($redirect['to'], $http_code);\n            exit;\n        }\n    }\n}\n\n/**\n * Simple method to allow support for multiple urls per post.\n *\n * Add custom post meta 'podlove_alternate_url' with old url part to match.\n *\n * @param mixed $value\n */\nfunction podlove_handle_episode_redirects($value = '')\n{\n    global $wpdb, $wp_query;\n\n    if (is_admin()) {\n        return;\n    }\n\n    if (!$wp_query->is_404) {\n        return;\n    }\n\n    // check for episode redirects\n    $rows = $wpdb->get_results('\n\t\tSELECT\n\t\t\tpost_id, meta_value url\n\t\tFROM\n\t\t\t'.$wpdb->postmeta.\"\n\t\tWHERE\n\t\t\tmeta_key = 'podlove_alternate_url'\n\t\", ARRAY_A);\n\n    $request_uri = untrailingslashit($_SERVER['REQUEST_URI']);\n    foreach ($rows as $row) {\n        if (false !== stripos($row['url'], $request_uri)) {\n            status_header(301);\n            $wp_query->is_404 = false;\n            wp_redirect(get_permalink($row['post_id']), 301);\n            exit;\n        }\n    }\n}\n"
  },
  {
    "path": "includes/request_id_rehash.php",
    "content": "<?php\n\nadd_action('admin_init', 'podlove_rehash_init_tools_section', 20);\nadd_action('admin_init', 'podlove_rehash_process_actions');\n\nfunction podlove_rehash_init_tools_section()\n{\n    \\Podlove\\add_tools_section('dsgvo', __('DSGVO', 'podlove-podcasting-plugin-for-wordpress'));\n\n    \\Podlove\\add_tools_field('dsgvo-rehash-request_ids', __('Rehash Request IDs', 'podlove-podcasting-plugin-for-wordpress'), function () {\n        ?>\n\t\t<a href=\"<?php echo esc_url(admin_url('admin.php?page='.$_REQUEST['page'].'&action=podlove_rehash_request_ids')); ?>\" class=\"button\">\n\t\t\t<?php echo __('Rehash Request IDs', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</a>\n\t\t<p class=\"description\">\n\t\t\t<?php echo __('Podlove Publisher tracking uses \"request ids\", which are hashes of request IP and User Agent. For better anonymity, IPs are truncated before they get hashed starting Podlove Publisher Version 2.7.4. To guarantee the anonymity of existing tracking data, request_ids must be rehashed here with a random salt.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<p class=\"description\">\n\t\t\t<?php echo __('Depending on your system and how much tracking data you have gathered, this might take a few hours. If you have hundreds of thousands of tracked downloads or more, you can speed up the process by doing the conversion over command line. See DSVGO guide for details.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n    }, 'dsgvo');\n}\n\nfunction podlove_rehash_process_actions()\n{\n    if (filter_input(INPUT_GET, 'page') != 'podlove_tools_settings_handle') {\n        return;\n    }\n\n    if (filter_input(INPUT_GET, 'action') != 'podlove_rehash_request_ids') {\n        return;\n    }\n\n    \\Podlove\\Jobs\\CronJobRunner::create_job('\\Podlove\\Jobs\\RequestIdRehashJob');\n\n    wp_redirect(admin_url('admin.php?page='.$_REQUEST['page']));\n}\n\nclass PodloveSilentProgressBar\n{\n    public function __construct($x = null) {}\n\n    public function display() {}\n\n    public function progress() {}\n\n    public function end() {}\n}\n\n/**\n * Rehash Tracking request_ids.\n *\n * This function is intended to be used by command line interface.\n * For use within Publisher, job class RequestIdRehashJob is preferred.\n *\n * Example\n *\n *     wp eval 'podlove_rehash_tracking_request_ids();'\n *\n * @param int $blog_id optional blog id for multisite\n */\nfunction podlove_rehash_tracking_request_ids($blog_id = null)\n{\n    if ($blog_id) {\n        if (!function_exists('switch_to_blog')) {\n            exit(\"You set a blog_id but this does not appear to be a multisite setup.\\n\");\n        }\n\n        switch_to_blog($blog_id);\n    }\n\n    $table = \\Podlove\\Model\\DownloadIntent::table_name();\n\n    podlove_rehash_log(\"Fetching request_ids from {$table} ...\\n\");\n\n    $request_ids = podlove_rehash_fetch_some_request_ids($table);\n\n    $total = count($request_ids);\n\n    if (!$total) {\n        podlove_rehash_log(\"Nothing to do.\\n\");\n\n        return;\n    }\n\n    podlove_rehash_log(\"Found request ids: {$total}\\n\\n\");\n\n    $progress_class = podlove_rehash_progress_class();\n    $bar = new $progress_class($total);\n    $bar->display();\n\n    $counter = 0;\n    foreach ($request_ids as $request_id) {\n        ++$counter;\n        $bar->progress();\n\n        podlove_rehash_replace_request_id($table, $request_id);\n    }\n    $bar->end();\n\n    if ($blog_id) {\n        restore_current_blog();\n    }\n}\n\n/**\n * Rehashes a request id.\n *\n * Until v2.7.3 it was possible with reasonable effort to brute force the IP\n * address from a request_id. To anonymize them, the following is done:\n *\n * - each unique request id is rehashed with a random salt\n * - rehashed request IDs are prefixed with \"DSGVO\" to mark them a \"ok\"\n *\n * @param mixed $table\n * @param mixed $request_id\n */\nfunction podlove_rehash_replace_request_id($table, $request_id)\n{\n    global $wpdb;\n\n    $salt = podlove_rehash_get_random_string();\n    $rehash = podlove_rehash_func($request_id, $salt);\n\n    $prepared = $wpdb->prepare(\n        \"UPDATE {$table} SET request_id = %s WHERE request_id = %s AND accessed_at < \\\"%s\\\"\",\n        [\n            $rehash,\n            $request_id,\n            podlove_rehash_unsalted_time(),\n        ]\n    );\n\n    $wpdb->query($prepared);\n}\n\nfunction podlove_rehash_fetch_some_request_ids($table, $limit = null)\n{\n    global $wpdb;\n\n    if ($limit) {\n        $limit_component = 'LIMIT '.(int) $limit;\n    } else {\n        $limit_component = '';\n    }\n\n    $sql = sprintf(\n        'SELECT DISTINCT request_id\n\t\tFROM `%s`\n\t\tWHERE\n\t\t  request_id NOT LIKE \"%s\"\n\t\t  AND accessed_at < \"%s\"\n\t\t%s',\n        $table,\n        podlove_rehash_prefix().'%',\n        podlove_rehash_unsalted_time(),\n        $limit_component\n    );\n\n    return $wpdb->get_col($sql);\n}\n\n/**\n * Returns upper time point for salting download intents.\n *\n * @return string DateTime in mysql format\n */\nfunction podlove_rehash_unsalted_time()\n{\n    $duration = strtotime('-1 day');\n    $duration = apply_filters('podlove_rehash_unsalted_duration', $duration);\n\n    return date('Y-m-d H:i:s', $duration);\n}\n\nfunction podlove_rehash_total_remaining($table)\n{\n    global $wpdb;\n\n    $sql = sprintf(\n        'select COUNT(distinct request_id) from %s WHERE request_id NOT LIKE \"%s\"',\n        $table,\n        podlove_rehash_prefix().'%'\n    );\n\n    return (int) $wpdb->get_var($sql);\n}\n\nfunction podlove_rehash_progress_class()\n{\n    if (php_sapi_name() == 'cli') {\n        return '\\Dariuszp\\CliProgressBar';\n    }\n\n    return '\\PodloveSilentProgressBar';\n}\n\nfunction podlove_rehash_log($message)\n{\n    if (php_sapi_name() == 'cli') {\n        print_r($message);\n    }\n}\n\nfunction podlove_rehash_get_random_string()\n{\n    if (function_exists('random_bytes')) {\n        return random_bytes(12);\n    }\n\n    if (function_exists('openssl_random_pseudo_bytes')) {\n        return bin2hex(openssl_random_pseudo_bytes(12));\n    }\n\n    return dechex(wp_rand()).dechex(wp_rand());\n}\n\nfunction podlove_rehash_func($old_hash, $salt)\n{\n    if (function_exists('openssl_digest')) {\n        return podlove_rehash_prefix().openssl_digest($old_hash.$salt, 'sha256');\n    }\n\n    if (function_exists('crypt')) {\n        return podlove_rehash_prefix().crypt($old_hash, $salt);\n    }\n\n    return podlove_rehash_prefix().sha1($old_hash.$salt);\n}\n\nfunction podlove_rehash_prefix()\n{\n    return 'DSGVO';\n}\n"
  },
  {
    "path": "includes/require_curl.php",
    "content": "<?php\nadd_action('admin_notices', function () {\n    $module_loaded = in_array('curl', get_loaded_extensions());\n    $function_disabled = stripos(ini_get('disable_functions'), 'curl_exec') !== false; ?>\n\t<?php if (!$module_loaded) { ?>\n\t\t<div class=\"notice notice-error\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podlove Publisher Error', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t<br>\n\t\t\t\t<?php echo __('Required PHP extension \"curl\" is not installed. Common solution:', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t<blockquote><code>sudo apt-get install php-curl</code></blockquote>\n\t\t\t\t<?php echo __('Then you need to restart you webserver.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t<?php\n                echo sprintf(\n                    __('If this does not help, visit %s for assistance.', 'podlove-podcasting-plugin-for-wordpress'),\n                    '<a href=\"https://community.podlove.org/c/podlove-publisher\" target=\"_blank\">community.podlove.org</a>'\n                ); ?>\n\t\t\t</p>\n\t\t</div>\n\t<?php } ?>\n\t<?php if ($function_disabled) { ?>\n\t\t<div class=\"notice notice-error\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podlove Publisher Error', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t<br>\n\t\t\t\t<?php echo __('Required PHP function \"curl_exec\" is disabled. You need to remove it from the list in the \"disable_functions\" setting in your php.ini. ', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t</div>\n\t<?php } ?>\n\t<?php\n});\n"
  },
  {
    "path": "includes/screen_options.php",
    "content": "<?php\n\nfunction podlove_episodes_per_page_option_name()\n{\n    return 'podlove_episodes_per_page';\n}\n\nadd_filter('set-screen-option', function ($status, $option, $value) {\n    if ($option == podlove_episodes_per_page_option_name()) {\n        return $value;\n    }\n\n    return $status;\n}, 10, 3);\n\nadd_action('admin_menu', function () {\n    add_action('load-'.\\Podlove\\Settings\\Analytics::$pagehook, function () {\n        add_screen_option('per_page', [\n            'label' => __('Episodes per page', 'podlove-podcasting-plugin-for-wordpress'),\n            'default' => 10,\n            'option' => podlove_episodes_per_page_option_name(),\n        ]);\n    });\n}, 20);\n"
  },
  {
    "path": "includes/scripts_and_styles.php",
    "content": "<?php\n\nfunction add_type_attribute($tag, $handle, $src)\n{\n    // if not your script, do nothing and return original $tag\n    if ('podlove-vue-app-client' !== $handle) {\n        return $tag;\n    }\n\n    // change the script tag by adding type=\"module\" and return it.\n    return '<script crossorigin type=\"module\" src=\"'.esc_url($src).'\"></script>';\n}\n\n// admin styles & scripts\nadd_action('admin_enqueue_scripts', function () {\n    $screen = get_current_screen();\n\n    $is_episode_edit_screen = \\Podlove\\is_episode_edit_screen();\n\n    $version = \\Podlove\\get_plugin_header('Version');\n\n    $vue_screens = [\n        'podlove_page_podlove_slackshownotes_settings',\n        'podlove_page_podlove_tools_settings_handle',\n        'podlove_page_podlove_analytics',\n        'podlove-setup-wizard',\n        'podlove_page_publisher_plus_settings',\n    ];\n\n    // vue job dashboard\n    if ($is_episode_edit_screen || in_array($screen->base, $vue_screens)) {\n        wp_register_script('podlove-episode-vue-apps', \\Podlove\\PLUGIN_URL.'/js/dist/app.js', ['underscore', 'jquery'], $version, true);\n        wp_register_script('podlove-vue-app-client', \\Podlove\\PLUGIN_URL.'/client/dist/client.js', ['wp-i18n'], $version, false);\n        add_filter('script_loader_tag', 'add_type_attribute', 10, 3);\n        wp_enqueue_style('podlove-vue-app-client-css', \\Podlove\\PLUGIN_URL.'/client/dist/style.css', [], $version);\n\n        $episode = Podlove\\Model\\Episode::find_or_create_by_post_id(get_the_ID());\n\n        if (!$episode) {\n            wp_localize_script(\n                'podlove-episode-vue-apps',\n                'podlove_vue',\n                [\n                    'rest_url' => esc_url_raw(rest_url()),\n                    'nonce' => wp_create_nonce('wp_rest'),\n                    'post_id' => get_the_ID(),\n                    'episode_id' => 0,\n                    'osf_active' => is_plugin_active('shownotes/shownotes.php'),\n                ]\n            );\n        } else {\n            wp_localize_script(\n                'podlove-episode-vue-apps',\n                'podlove_vue',\n                [\n                    'rest_url' => esc_url_raw(rest_url()),\n                    'nonce' => wp_create_nonce('wp_rest'),\n                    'post_id' => get_the_ID(),\n                    'episode_id' => $episode->id,\n                    'osf_active' => is_plugin_active('shownotes/shownotes.php'),\n                ]\n            );\n\n            add_filter('podlove_data_js', function ($data) use ($episode) {\n                $data['episode'] = [\n                    'duration' => $episode->duration,\n                    'id' => $episode->id\n                ];\n\n                $data['post'] = [\n                    'id' => get_the_ID()\n                ];\n\n                $data['api'] = [\n                    'base' => esc_url_raw(rest_url('podlove')),\n                    'nonce' => wp_create_nonce('wp_rest'),\n                ];\n\n                $assignments = \\Podlove\\Model\\AssetAssignment::get_instance();\n\n                $data['assignments'] = [\n                    'image' => $assignments->image,\n                    'chapters' => $assignments->chapters,\n                    'transcript' => $assignments->transcript\n                ];\n\n                return $data;\n            });\n        }\n\n        wp_set_script_translations('podlove-vue-app-client', 'podlove-podcasting-plugin-for-wordpress');\n\n        wp_enqueue_script('podlove-episode-vue-apps');\n        wp_enqueue_script('podlove-vue-app-client');\n    }\n\n    if (\\Podlove\\is_podlove_settings_screen() || $is_episode_edit_screen) {\n        wp_enqueue_style('podlove-admin', \\Podlove\\PLUGIN_URL.'/css/admin.css', [], $version);\n        wp_enqueue_style('podlove-admin-font', \\Podlove\\PLUGIN_URL.'/css/admin-font.css', [], $version);\n\n        // chosen.js scripts & styles\n        wp_enqueue_style('podlove-admin-chosen', \\Podlove\\PLUGIN_URL.'/js/admin/chosen/chosen.min.css', [], $version);\n        wp_enqueue_style('podlove-admin-image-chosen', \\Podlove\\PLUGIN_URL.'/js/admin/chosen/chosenImage.css', [], $version);\n\n        wp_enqueue_script('podlove_admin', \\Podlove\\PLUGIN_URL.'/js/dist/podlove-admin.js', [\n            'jquery', 'jquery-ui-sortable', 'jquery-ui-datepicker',\n        ], $version);\n\n        wp_enqueue_style('jquery-ui-style', \\Podlove\\PLUGIN_URL.'/js/admin/jquery-ui/css/smoothness/jquery-ui.css');\n\n        wp_localize_script(\n            'podlove_admin',\n            'podlove_admin_global',\n            [\n                'rest_url' => esc_url_raw(rest_url()),\n                'nonce' => wp_create_nonce('wp_rest'),\n                'nonce_ajax' => wp_create_nonce('podlove_ajax'),\n                'post_id' => get_the_ID(),\n            ]\n        );\n    }\n});\n"
  },
  {
    "path": "includes/search.php",
    "content": "<?php\n\nfunction podlove_is_search_query($query)\n{\n    if (!isset($query->query_vars['search_terms'])) {\n        return false;\n    }\n\n    if (isset($query->query_vars['suppress_filters']) && true == $query->query_vars['suppress_filters']) {\n        return false;\n    }\n\n    if ($query->is_feed()) {\n        return false;\n    }\n\n    return $query->is_search();\n}\n\n/*\n * Extend/Replace WordPress core search logic to include episode fields.\n *\n * The way I do it here is not well-behaving. If other plugins modify the query\n * before me, their changes will be overridden. However, there is no better\n * place to hook into and I refuse to modify the filterable query string with\n * regular expressions.\n *\n * If you found this piece of code and are now cursing at me, please get in\n * touch.\n */\nadd_filter('posts_search', function ($search, $query) {\n    global $wpdb;\n\n    if (!podlove_is_search_query($query)) {\n        return $search;\n    }\n\n    $episodesTable = \\Podlove\\Model\\Episode::table_name();\n\n    $search = '';\n    $searchand = '';\n    $n = !empty($query->query_vars['exact']) ? '' : '%';\n    foreach ((array) $query->query_vars['search_terms'] as $term) {\n        $term = esc_sql(\\Podlove\\esc_like($term));\n        $search .= \"\n\t\t\t{$searchand}\n\t\t\t(\n\t\t\t\t({$wpdb->posts}.post_title LIKE '{$n}{$term}{$n}')\n\t\t\t\tOR\n\t\t\t\t({$wpdb->posts}.post_content LIKE '{$n}{$term}{$n}')\n\t\t\t\tOR\n\t\t\t\t({$episodesTable}.subtitle LIKE '{$n}{$term}{$n}')\n\t\t\t\tOR\n\t\t\t\t({$episodesTable}.summary LIKE '{$n}{$term}{$n}')\n\t\t\t\tOR\n\t\t\t\t({$episodesTable}.chapters LIKE '{$n}{$term}{$n}')\n\t\t\t)\";\n        $searchand = ' AND ';\n    }\n\n    if (!empty($search)) {\n        $search = \" AND ({$search}) \";\n        if (!is_user_logged_in()) {\n            $search .= \" AND ({$wpdb->posts}.post_password = '') \";\n        }\n    }\n\n    return $search;\n}, 10, 2);\n\n// join into episode table in WordPress searches so we can access episode fields\nadd_filter('posts_join', function ($join, $query) {\n    global $wpdb;\n\n    if (!podlove_is_search_query($query)) {\n        return $join;\n    }\n\n    $episodesTable = \\Podlove\\Model\\Episode::table_name();\n    $join .= \" LEFT JOIN {$episodesTable} ON {$wpdb->posts}.ID = {$episodesTable}.post_id \";\n\n    return $join;\n}, 10, 2);\n"
  },
  {
    "path": "includes/setup.php",
    "content": "<?php\n\n/**\n * Plugin Setup.\n */\n\nuse Podlove\\Model;\nuse Podlove\\Model\\AssetAssignment;\nuse Ramsey\\Uuid\\Uuid as UUID;\n\nfunction podlove_setup_database_tables()\n{\n    Model\\Feed::build();\n    Model\\FileType::build();\n    Model\\EpisodeAsset::build();\n    Model\\MediaFile::build();\n    Model\\Episode::build();\n    Model\\Template::build();\n    Model\\DownloadIntent::build();\n    Model\\DownloadIntentClean::build();\n    Model\\UserAgent::build();\n    Model\\GeoArea::build();\n    Model\\GeoAreaName::build();\n    Model\\Job::build();\n}\n\nfunction podlove_setup_file_types()\n{\n    if (Model\\FileType::has_entries()) {\n        return;\n    }\n\n    $default_types = [\n        ['name' => 'MP3 Audio', 'type' => 'audio', 'mime_type' => 'audio/mpeg', 'extension' => 'mp3'],\n        ['name' => 'BitTorrent (MP3 Audio)', 'type' => 'audio', 'mime_type' => 'application/x-bittorrent', 'extension' => 'mp3.torrent'],\n        ['name' => 'MPEG-1 Video', 'type' => 'video', 'mime_type' => 'video/mpeg', 'extension' => 'mpg'],\n        ['name' => 'MPEG-4 AAC Audio', 'type' => 'audio', 'mime_type' => 'audio/mp4', 'extension' => 'm4a'],\n        ['name' => 'MPEG-4 ALAC Audio', 'type' => 'audio', 'mime_type' => 'audio/mp4', 'extension' => 'm4a'],\n        ['name' => 'MPEG-4 Video', 'type' => 'video', 'mime_type' => 'video/mp4', 'extension' => 'mp4'],\n        ['name' => 'M4V Video (Apple)', 'type' => 'video', 'mime_type' => 'video/x-m4v', 'extension' => 'm4v'],\n        ['name' => 'Ogg Vorbis Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg', 'extension' => 'oga'],\n        ['name' => 'Ogg Vorbis Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg', 'extension' => 'ogg'],\n        ['name' => 'Ogg Theora Video', 'type' => 'video', 'mime_type' => 'video/ogg', 'extension' => 'ogv'],\n        ['name' => 'WebM Audio', 'type' => 'audio', 'mime_type' => 'audio/webm', 'extension' => 'webm'],\n        ['name' => 'WebM Video', 'type' => 'video', 'mime_type' => 'video/webm', 'extension' => 'webm'],\n        ['name' => 'FLAC Audio', 'type' => 'audio', 'mime_type' => 'audio/flac', 'extension' => 'flac'],\n        ['name' => 'Opus Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg;codecs=opus', 'extension' => 'opus'],\n        ['name' => 'Matroska Audio', 'type' => 'audio', 'mime_type' => 'audio/x-matroska', 'extension' => 'mka'],\n        ['name' => 'Matroska Video', 'type' => 'video', 'mime_type' => 'video/x-matroska', 'extension' => 'mkv'],\n        ['name' => 'PDF Document', 'type' => 'ebook', 'mime_type' => 'application/pdf', 'extension' => 'pdf'],\n        ['name' => 'ePub Document', 'type' => 'ebook', 'mime_type' => 'application/epub+zip', 'extension' => 'epub'],\n        ['name' => 'PNG Image', 'type' => 'image', 'mime_type' => 'image/png', 'extension' => 'png'],\n        ['name' => 'JPEG Image', 'type' => 'image', 'mime_type' => 'image/jpeg', 'extension' => 'jpg'],\n        ['name' => 'mp4chaps Chapter File', 'type' => 'chapters', 'mime_type' => 'text/plain', 'extension' => 'chapters.txt'],\n        ['name' => 'Podlove Simple Chapters', 'type' => 'chapters', 'mime_type' => 'application/xml', 'extension' => 'psc'],\n        ['name' => 'Subrip Captions', 'type' => 'captions', 'mime_type' => 'application/x-subrip', 'extension' => 'srt'],\n        ['name' => 'WebVTT Captions', 'type' => 'captions', 'mime_type' => 'text/vtt', 'extension' => 'vtt'],\n        ['name' => 'WebVTT Captions', 'type' => 'transcript', 'mime_type' => 'text/vtt', 'extension' => 'vtt'],\n        ['name' => 'Auphonic Production Description', 'type' => 'metadata', 'mime_type' => 'application/json', 'extension' => 'json'],\n        ['name' => 'Podigee Transcript', 'type' => 'transcript', 'mime_type' => 'plain/text', 'extension' => 'txt'],\n    ];\n\n    foreach ($default_types as $file_type) {\n        $f = new Model\\FileType();\n        foreach ($file_type as $key => $value) {\n            $f->{$key} = $value;\n        }\n        $f->save();\n    }\n}\n\nfunction podlove_setup_podcast()\n{\n    $podcast = Model\\Podcast::get();\n\n    if (!$podcast->limit_items) {\n        $podcast->limit_items = Model\\Feed::ITEMS_NO_LIMIT;\n        $podcast->feed_transcripts = 'generated';\n    }\n\n    if (!$podcast->guid) {\n        $podcast->guid = UUID::uuid4();\n    }\n\n    $podcast->save();\n}\n\nfunction podlove_setup_modules()\n{\n    // required for all module hooks to fire correctly\n    add_option('podlove_active_modules', []);\n\n    // set default modules\n    $default_modules = [\n        'logging',\n        'podlove_web_player',\n        'open_graph',\n        'plus',\n        // 'asset_validation',\n        'oembed',\n        // 'feed_validation',\n        'import_export',\n        'subscribe_button',\n        'automatic_numbering',\n        'onboarding'\n    ];\n\n    foreach ($default_modules as $module) {\n        \\Podlove\\Modules\\Base::activate($module);\n    }\n}\n\nfunction podlove_setup_expert_settings()\n{\n    if (get_option('podlove', []) !== []) {\n        return;\n    }\n\n    update_option('podlove', [\n        'merge_episodes' => 'on',\n        'hide_wp_feed_discovery' => 'off',\n        'use_post_permastruct' => 'on',\n        'episode_archive' => 'on',\n        'episode_archive_slug' => '/podcast/',\n        'custom_episode_slug' => '/podcast/%podcast%/',\n    ]);\n}\n\nfunction podlove_setup_default_template()\n{\n    $template = Model\\Template::find_one_by_property('title', 'default');\n\n    if ($template) {\n        return;\n    }\n\n    // set default template\n    $template = new Model\\Template();\n    $template->title = 'default';\n    $template->content = <<<'EOT'\n{% if not is_feed() %}\n\n  {# display web player for episode #}\n  {{ episode.player }}\n\n{% endif %}\n\n{# display contributors if module is active #}\n{% if shortcode_exists(\"podlove-episode-contributor-list\") %}\n  {# see http://docs.podlove.org/podlove-publisher/reference/shortcodes.html#contributors for parameters #}\n  [podlove-episode-contributor-list]\n{% endif %}\n\nEOT;\n    $template->save();\n\n    $assignment = Model\\TemplateAssignment::get_instance();\n    $assignment->top = $template->title;\n    $assignment->save();\n}\n\nfunction podlove_setup_default_media()\n{\n    if (Model\\EpisodeAsset::has_entries()) {\n        return;\n    }\n\n    $asset = new Model\\EpisodeAsset();\n    $asset->file_type_id = Model\\FileType::find_one_by_property('extension', 'mp3')->id;\n    $asset->title = 'MP3 Audio';\n    $asset->downloadable = 1;\n    $asset->save();\n\n    $feed = new Model\\Feed();\n    $feed->episode_asset_id = $asset->id;\n    $feed->name = 'MP3 Feed';\n    $feed->title = 'MP3 Feed';\n    $feed->slug = 'mp3';\n    $feed->enable = 1;\n    $feed->discoverable = 1;\n    $feed->limit_items = Model\\Feed::ITEMS_GLOBAL_LIMIT;\n    $feed->embed_content_encoded = 1;\n    $feed->save();\n}\n\nfunction podlove_setup_default_asset_assignments()\n{\n    $assignment = AssetAssignment::get_instance();\n\n    if (!$assignment->image) {\n        $assignment->image = 'post-thumbnail';\n        $assignment->chapters = 'manual';\n        $assignment->save();\n    }\n}\n"
  },
  {
    "path": "includes/setup_wizard.php",
    "content": "<?php\n// status: skeleton page, needs enabling via constant\n\nnamespace Podlove\\Wizard;\n\ndefine('PODLOVE_WIZARD_URL_KEY', 'podlove-setup-wizard');\n\nadd_action('wp_loaded', '\\Podlove\\Wizard\\wizard_page', 9);\nadd_action('admin_init', '\\Podlove\\Wizard\\maybe_redirect_to_wizard_page', 10);\n\nfunction maybe_redirect_to_wizard_page()\n{\n    // enable with:\n    // define('PODLOVE_WIZARD_ENABLED', true);\n    if (!(defined('PODLOVE_WIZARD_ENABLED') && PODLOVE_WIZARD_ENABLED)) {\n        return;\n    }\n\n    // allow to disable wizard via filter\n    if (!apply_filters('podlove_enable_setup_wizard', true)) {\n        return;\n    }\n\n    // don't redirect when we're on the page\n    if ($_GET['page'] == PODLOVE_WIZARD_URL_KEY) {\n        return;\n    }\n\n    // don't redirect when it does not make technically sense\n    if (wp_doing_ajax() || is_network_admin()) {\n        return;\n    }\n\n    // missing checks:\n    // - user has enough permissions\n    // - wizard is done\n\n    wp_safe_redirect(admin_url('admin.php?page='.PODLOVE_WIZARD_URL_KEY));\n\n    exit;\n}\n\nfunction wizard_page()\n{\n    if (!isset($_GET['page'])) {\n        return;\n    }\n\n    if ($_GET['page'] != PODLOVE_WIZARD_URL_KEY) {\n        return;\n    }\n\n    wp_enqueue_script('podlove-episode-vue-apps', \\Podlove\\PLUGIN_URL.'/js/dist/app.js', ['underscore', 'jquery']); ?><!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <?php wp_head(); ?>\n    <title>Podlove Publisher | Setup Wizard</title>\n</head>\n<body>\n    <div id=\"podlove-setup-wizard\"></div>\n    <?php wp_footer(); ?>\n</body>\n</html>\n    <?php\n    exit;\n}\n"
  },
  {
    "path": "includes/system_report.php",
    "content": "<?php\n\n/**\n * System Report needs to be run whenever a setting has changed that could effect something critical.\n */\nfunction podlove_run_system_report()\n{\n    $report = new Podlove\\SystemReport();\n    $report->run();\n}\n\nadd_action('update_option_permalink_structure', 'podlove_run_system_report');\nadd_action('update_option_podlove', 'podlove_run_system_report');\n"
  },
  {
    "path": "includes/template_pages.php",
    "content": "<?php\n\nnamespace Podlove\\TemplatePages;\n\nuse Podlove\\Model\\Template;\n\nadd_action('template_redirect', '\\Podlove\\TemplatePages\\intercept_template');\n\nfunction intercept_template()\n{\n    $podlove_template_key = filter_input(INPUT_GET, 'podlove_template_page', FILTER_SANITIZE_SPECIAL_CHARS);\n\n    if (!$podlove_template_key) {\n        return;\n    }\n\n    $template = Template::find_one_by_title_with_fallback($podlove_template_key);\n\n    if (!$template) {\n        return;\n    }\n\n    echo \\Podlove\\template_shortcode(['template' => $template->title]);\n\n    exit;\n}\n"
  },
  {
    "path": "includes/templates.php",
    "content": "<?php\n\nuse Podlove\\Model\\Template;\nuse Podlove\\Model\\TemplateAssignment;\n\nadd_filter('the_content', 'podlove_autoinsert_templates_into_content');\nadd_action('wp_head', 'podlove_autoinsert_templates_head');\nadd_action('wp_footer', 'podlove_autoinsert_templates_footer');\nadd_action('wp_body_open', 'podlove_autoinsert_templates_header');\n\nfunction podlove_autoinsert_templates_into_content($content)\n{\n    if (get_post_type() !== 'podcast' || post_password_required()) {\n        return $content;\n    }\n\n    $template_assignments = TemplateAssignment::get_instance();\n\n    if ($template_assignments->top) {\n        if ($template = Template::find_one_by_title_with_fallback($template_assignments->top)) {\n            $shortcode = '[podlove-template template=\"'.$template->title.'\"]';\n            if (stripos($content, $shortcode) === false) {\n                $content = $shortcode.$content;\n            }\n        }\n    }\n\n    if ($template_assignments->bottom) {\n        if ($template = Template::find_one_by_title_with_fallback($template_assignments->bottom)) {\n            $shortcode = '[podlove-template template=\"'.$template->title.'\"]';\n            if (stripos($content, $shortcode) === false) {\n                $content = $content.$shortcode;\n            }\n        }\n    }\n\n    return $content;\n}\n\nfunction podlove_autoinsert_templates_head()\n{\n    $template_assignments = TemplateAssignment::get_instance();\n\n    if ($template_assignments->head) {\n        if ($template = Template::find_one_by_title_with_fallback($template_assignments->head)) {\n            echo \\Podlove\\template_shortcode([\n                'template' => $template->title,\n            ]);\n        }\n    }\n}\n\nfunction podlove_autoinsert_templates_footer()\n{\n    $template_assignments = TemplateAssignment::get_instance();\n\n    if ($template_assignments->footer) {\n        if ($template = Template::find_one_by_title_with_fallback($template_assignments->footer)) {\n            echo \\Podlove\\template_shortcode([\n                'template' => $template->title,\n            ]);\n        }\n    }\n}\n\nfunction podlove_autoinsert_templates_header()\n{\n    $template_assignments = TemplateAssignment::get_instance();\n\n    if ($template_assignments->header) {\n        if ($template = Template::find_one_by_title_with_fallback($template_assignments->header)) {\n            echo \\Podlove\\template_shortcode([\n                'template' => $template->title,\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "includes/theme_helper.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Get Podlove episode template object.\n *\n * @param null|mixed $id\n *\n * @return \\Podlove\\Template\\Episode\n */\nfunction get_episode($id = null)\n{\n    $post = get_post($id);\n\n    if (!$post) {\n        return null;\n    }\n\n    $episode = Model\\Episode::find_one_by_property('post_id', $post->ID);\n\n    if (!$episode) {\n        return null;\n    }\n\n    return new Template\\Episode($episode);\n}\n\n/**\n * Get Podlove podcast template object.\n *\n * @param int $blog_id Optional. Blog ID. Defaults to global $blog_id.\n *\n * @return \\Podlove\\Template\\Podcast\n */\nfunction get_podcast($blog_id = null)\n{\n    return new Template\\Podcast(Model\\Podcast::get($blog_id));\n}\n\n/**\n * Get Podlove network template object.\n *\n * Only available in WordPress Multisite environments.\n *\n * @return \\Podlove\\Modules\\Networks\\Template\\Network\n */\nfunction get_network()\n{\n    return new \\Podlove\\Modules\\Networks\\Template\\Network();\n}\n"
  },
  {
    "path": "includes/trash.php",
    "content": "<?php\n\nadd_filter('posts_results', 'podlove_remove_trash_posts_from_the_posts', 10, 2);\n\n/**\n * Filters trashed, imported posts from our posts out.\n *\n * @param mixed $posts\n * @param mixed $wp_query\n */\nfunction podlove_remove_trash_posts_from_the_posts($posts, $wp_query)\n{\n    global $wp_the_query;\n\n    // Apply filter not in the backend and only on the main query\n    if ($wp_query->is_admin && $wp_the_query == $wp_query) {\n        return $posts;\n    }\n\n    // No post request\n    if (isset($wp_query->query['preview']) || false === (isset($wp_query->query['name']) || isset($wp_query->query['p']))) {\n        return $posts;\n    }\n\n    // Only check if we found more than 2 posts\n    if (2 > count($posts)) {\n        return $posts;\n    }\n\n    // Remove trashed posts\n    foreach ($posts as $index => $post) {\n        if ('trash' == $post->post_status) {\n            unset($posts[$index]);\n        }\n    }\n\n    // Resets array keys\n    $posts = array_values($posts);\n\n    return $posts;\n}\n"
  },
  {
    "path": "includes/verify_itunes_category.php",
    "content": "<?php\nadd_action('admin_notices', 'podlove_verify_itunes_category');\n\nfunction podlove_verify_itunes_category()\n{\n    $podcast = \\Podlove\\Model\\Podcast::get();\n    $category = $podcast->category_1;\n\n    if (!$category) {\n        return;\n    }\n\n    if (array_key_exists($category, \\Podlove\\Itunes\\categories(false))) {\n        return;\n    } ?>\n\t\t<div class=\"notice notice-error\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podlove Publisher Warning', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t<br>\n        <?php echo __('Apple iTunes has updated the list of existing podcast categories. Your previously selected category does not exist any more. Please choose a new one:', 'podlove-podcasting-plugin-for-wordpress'); ?>\n        <br>\n        <a href=\"<?php echo admin_url('admin.php?page=podlove_settings_podcast_handle&podlove_tab=directory'); ?>\">\n          <?php echo __('Podcast Directory Settings', 'podlove-podcasting-plugin-for-wordpress'); ?>\n        </a>\n\t\t\t</p>\n\t\t</div>\n    <?php\n}\n"
  },
  {
    "path": "includes/webhooks.php",
    "content": "<?php\n\n/**\n * Quick local debugging.\n *\n * Put this in your wp-config.php:\n *\n *     define('PODLOVE_WEBHOOKS', [\n *         'episode_updated' => 'http://localhost:10003/?webhook_debugger=1',\n *     ]);\n *\n * And uncomment this or put it somewhere where it get executed:\n *\n *     add_action('init', function () {\n *         if (isset($_REQUEST['webhook_debugger'])) {\n *             error_log(print_r($_REQUEST, true));\n *         }\n *     });\n */\n\nuse Podlove\\Model;\nuse Podlove\\Webhook\\Webhook;\n\nadd_action('podlove_fire_webhook', 'podlove_fire_webhook', 10, 4);\n\nfunction podlove_fire_webhook($event, $method, $payload, $url)\n{\n    $webhook = new Webhook($event);\n    $webhook\n        ->method($method)\n        ->payload($payload)\n        ->send($url)\n    ;\n}\n\nfunction podlove_init_webhooks($config)\n{\n    if (empty($config)) {\n        return;\n    }\n\n    if (isset($config['episode_updated'])) {\n        add_action('podlove_episode_content_has_changed', function ($episode_id) use ($config) {\n            $event = 'episode_updated';\n            if ($episode = Model\\Episode::find_by_id($episode_id)) {\n                wp_schedule_single_event(time() + 1, 'podlove_fire_webhook', [\n                    'event' => $event,\n                    'method' => 'POST',\n                    'payload' => ['episode' => $episode->to_array()],\n                    'url' => $config[$event]\n                ]);\n            }\n        });\n    }\n}\n\nif (defined('PODLOVE_WEBHOOKS')) {\n    podlove_init_webhooks(PODLOVE_WEBHOOKS);\n}\n"
  },
  {
    "path": "includes/wp_rocket.php",
    "content": "<?php\n\n// WP-Rocket plugin compatibility\n//\n// via:\n//   - https://sendegate.de/t/webplayer-2-podigee-wird-nur-nach-anmeldung-gezeigt/4586/4?u=ericteubert\n//   - http://docs.wp-rocket.me/article/19-resolving-issues-with-minification\n//   - http://docs.wp-rocket.me/article/39-excluding-external-js-from-minification\nfunction podlove_fix_wprocket_excluded_external_js($external_js)\n{\n    // exclude our externals since it creates issues if they are mashed together\n    $external_js[] = 'cdn.podigee.com';\n    $external_js[] = 'cdn.podlove.org';\n\n    return $external_js;\n}\n\nadd_filter('rocket_minify_excluded_external_js', 'podlove_fix_wprocket_excluded_external_js');\n"
  },
  {
    "path": "js/.tool-versions",
    "content": "nodejs 18.18.2\n"
  },
  {
    "path": "js/admin/ace/ace.js",
    "content": "(function(){function o(n){var i=e;n&&(e[n]||(e[n]={}),i=e[n]);if(!i.define||!i.define.packaged)t.original=i.define,i.define=t,i.define.packaged=!0;if(!i.require||!i.require.packaged)r.original=i.require,i.require=r,i.require.packaged=!0}var ACE_NAMESPACE=\"\",e=function(){return this}();!e&&typeof window!=\"undefined\"&&(e=window);if(!ACE_NAMESPACE&&typeof requirejs!=\"undefined\")return;var t=function(e,n,r){if(typeof e!=\"string\"){t.original?t.original.apply(this,arguments):(console.error(\"dropping module because define wasn't a string.\"),console.trace());return}arguments.length==2&&(r=n),t.modules[e]||(t.payloads[e]=r,t.modules[e]=null)};t.modules={},t.payloads={};var n=function(e,t,n){if(typeof t==\"string\"){var i=s(e,t);if(i!=undefined)return n&&n(),i}else if(Object.prototype.toString.call(t)===\"[object Array]\"){var o=[];for(var u=0,a=t.length;u<a;++u){var f=s(e,t[u]);if(f==undefined&&r.original)return;o.push(f)}return n&&n.apply(null,o)||!0}},r=function(e,t){var i=n(\"\",e,t);return i==undefined&&r.original?r.original.apply(this,arguments):i},i=function(e,t){if(t.indexOf(\"!\")!==-1){var n=t.split(\"!\");return i(e,n[0])+\"!\"+i(e,n[1])}if(t.charAt(0)==\".\"){var r=e.split(\"/\").slice(0,-1).join(\"/\");t=r+\"/\"+t;while(t.indexOf(\".\")!==-1&&s!=t){var s=t;t=t.replace(/\\/\\.\\//,\"/\").replace(/[^\\/]+\\/\\.\\.\\//,\"\")}}return t},s=function(e,r){r=i(e,r);var s=t.modules[r];if(!s){s=t.payloads[r];if(typeof s==\"function\"){var o={},u={id:r,uri:\"\",exports:o,packaged:!0},a=function(e,t){return n(r,e,t)},f=s(a,o,u);o=f||u.exports,t.modules[r]=o,delete t.payloads[r]}s=t.modules[r]=o||s}return s};o(ACE_NAMESPACE)})(),define(\"ace/lib/regexp\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";function o(e){return(e.global?\"g\":\"\")+(e.ignoreCase?\"i\":\"\")+(e.multiline?\"m\":\"\")+(e.extended?\"x\":\"\")+(e.sticky?\"y\":\"\")}function u(e,t,n){if(Array.prototype.indexOf)return e.indexOf(t,n);for(var r=n||0;r<e.length;r++)if(e[r]===t)return r;return-1}var r={exec:RegExp.prototype.exec,test:RegExp.prototype.test,match:String.prototype.match,replace:String.prototype.replace,split:String.prototype.split},i=r.exec.call(/()??/,\"\")[1]===undefined,s=function(){var e=/^/g;return r.test.call(e,\"\"),!e.lastIndex}();if(s&&i)return;RegExp.prototype.exec=function(e){var t=r.exec.apply(this,arguments),n,a;if(typeof e==\"string\"&&t){!i&&t.length>1&&u(t,\"\")>-1&&(a=RegExp(this.source,r.replace.call(o(this),\"g\",\"\")),r.replace.call(e.slice(t.index),a,function(){for(var e=1;e<arguments.length-2;e++)arguments[e]===undefined&&(t[e]=undefined)}));if(this._xregexp&&this._xregexp.captureNames)for(var f=1;f<t.length;f++)n=this._xregexp.captureNames[f-1],n&&(t[n]=t[f]);!s&&this.global&&!t[0].length&&this.lastIndex>t.index&&this.lastIndex--}return t},s||(RegExp.prototype.test=function(e){var t=r.exec.call(this,e);return t&&this.global&&!t[0].length&&this.lastIndex>t.index&&this.lastIndex--,!!t})}),define(\"ace/lib/es5-shim\",[\"require\",\"exports\",\"module\"],function(e,t,n){function r(){}function w(e){try{return Object.defineProperty(e,\"sentinel\",{}),\"sentinel\"in e}catch(t){}}function H(e){return e=+e,e!==e?e=0:e!==0&&e!==1/0&&e!==-1/0&&(e=(e>0||-1)*Math.floor(Math.abs(e))),e}function B(e){var t=typeof e;return e===null||t===\"undefined\"||t===\"boolean\"||t===\"number\"||t===\"string\"}function j(e){var t,n,r;if(B(e))return e;n=e.valueOf;if(typeof n==\"function\"){t=n.call(e);if(B(t))return t}r=e.toString;if(typeof r==\"function\"){t=r.call(e);if(B(t))return t}throw new TypeError}Function.prototype.bind||(Function.prototype.bind=function(t){var n=this;if(typeof n!=\"function\")throw new TypeError(\"Function.prototype.bind called on incompatible \"+n);var i=u.call(arguments,1),s=function(){if(this instanceof s){var e=n.apply(this,i.concat(u.call(arguments)));return Object(e)===e?e:this}return n.apply(t,i.concat(u.call(arguments)))};return n.prototype&&(r.prototype=n.prototype,s.prototype=new r,r.prototype=null),s});var i=Function.prototype.call,s=Array.prototype,o=Object.prototype,u=s.slice,a=i.bind(o.toString),f=i.bind(o.hasOwnProperty),l,c,h,p,d;if(d=f(o,\"__defineGetter__\"))l=i.bind(o.__defineGetter__),c=i.bind(o.__defineSetter__),h=i.bind(o.__lookupGetter__),p=i.bind(o.__lookupSetter__);if([1,2].splice(0).length!=2)if(!function(){function e(e){var t=new Array(e+2);return t[0]=t[1]=0,t}var t=[],n;t.splice.apply(t,e(20)),t.splice.apply(t,e(26)),n=t.length,t.splice(5,0,\"XXX\"),n+1==t.length;if(n+1==t.length)return!0}())Array.prototype.splice=function(e,t){var n=this.length;e>0?e>n&&(e=n):e==void 0?e=0:e<0&&(e=Math.max(n+e,0)),e+t<n||(t=n-e);var r=this.slice(e,e+t),i=u.call(arguments,2),s=i.length;if(e===n)s&&this.push.apply(this,i);else{var o=Math.min(t,n-e),a=e+o,f=a+s-o,l=n-a,c=n-o;if(f<a)for(var h=0;h<l;++h)this[f+h]=this[a+h];else if(f>a)for(h=l;h--;)this[f+h]=this[a+h];if(s&&e===c)this.length=c,this.push.apply(this,i);else{this.length=c+s;for(h=0;h<s;++h)this[e+h]=i[h]}}return r};else{var v=Array.prototype.splice;Array.prototype.splice=function(e,t){return arguments.length?v.apply(this,[e===void 0?0:e,t===void 0?this.length-e:t].concat(u.call(arguments,2))):[]}}Array.isArray||(Array.isArray=function(t){return a(t)==\"[object Array]\"});var m=Object(\"a\"),g=m[0]!=\"a\"||!(0 in m);Array.prototype.forEach||(Array.prototype.forEach=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=arguments[1],s=-1,o=r.length>>>0;if(a(t)!=\"[object Function]\")throw new TypeError;while(++s<o)s in r&&t.call(i,r[s],s,n)}),Array.prototype.map||(Array.prototype.map=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0,s=Array(i),o=arguments[1];if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");for(var u=0;u<i;u++)u in r&&(s[u]=t.call(o,r[u],u,n));return s}),Array.prototype.filter||(Array.prototype.filter=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0,s=[],o,u=arguments[1];if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");for(var f=0;f<i;f++)f in r&&(o=r[f],t.call(u,o,f,n)&&s.push(o));return s}),Array.prototype.every||(Array.prototype.every=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0,s=arguments[1];if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");for(var o=0;o<i;o++)if(o in r&&!t.call(s,r[o],o,n))return!1;return!0}),Array.prototype.some||(Array.prototype.some=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0,s=arguments[1];if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");for(var o=0;o<i;o++)if(o in r&&t.call(s,r[o],o,n))return!0;return!1}),Array.prototype.reduce||(Array.prototype.reduce=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0;if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");if(!i&&arguments.length==1)throw new TypeError(\"reduce of empty array with no initial value\");var s=0,o;if(arguments.length>=2)o=arguments[1];else do{if(s in r){o=r[s++];break}if(++s>=i)throw new TypeError(\"reduce of empty array with no initial value\")}while(!0);for(;s<i;s++)s in r&&(o=t.call(void 0,o,r[s],s,n));return o}),Array.prototype.reduceRight||(Array.prototype.reduceRight=function(t){var n=F(this),r=g&&a(this)==\"[object String]\"?this.split(\"\"):n,i=r.length>>>0;if(a(t)!=\"[object Function]\")throw new TypeError(t+\" is not a function\");if(!i&&arguments.length==1)throw new TypeError(\"reduceRight of empty array with no initial value\");var s,o=i-1;if(arguments.length>=2)s=arguments[1];else do{if(o in r){s=r[o--];break}if(--o<0)throw new TypeError(\"reduceRight of empty array with no initial value\")}while(!0);do o in this&&(s=t.call(void 0,s,r[o],o,n));while(o--);return s});if(!Array.prototype.indexOf||[0,1].indexOf(1,2)!=-1)Array.prototype.indexOf=function(t){var n=g&&a(this)==\"[object String]\"?this.split(\"\"):F(this),r=n.length>>>0;if(!r)return-1;var i=0;arguments.length>1&&(i=H(arguments[1])),i=i>=0?i:Math.max(0,r+i);for(;i<r;i++)if(i in n&&n[i]===t)return i;return-1};if(!Array.prototype.lastIndexOf||[0,1].lastIndexOf(0,-3)!=-1)Array.prototype.lastIndexOf=function(t){var n=g&&a(this)==\"[object String]\"?this.split(\"\"):F(this),r=n.length>>>0;if(!r)return-1;var i=r-1;arguments.length>1&&(i=Math.min(i,H(arguments[1]))),i=i>=0?i:r-Math.abs(i);for(;i>=0;i--)if(i in n&&t===n[i])return i;return-1};Object.getPrototypeOf||(Object.getPrototypeOf=function(t){return t.__proto__||(t.constructor?t.constructor.prototype:o)});if(!Object.getOwnPropertyDescriptor){var y=\"Object.getOwnPropertyDescriptor called on a non-object: \";Object.getOwnPropertyDescriptor=function(t,n){if(typeof t!=\"object\"&&typeof t!=\"function\"||t===null)throw new TypeError(y+t);if(!f(t,n))return;var r,i,s;r={enumerable:!0,configurable:!0};if(d){var u=t.__proto__;t.__proto__=o;var i=h(t,n),s=p(t,n);t.__proto__=u;if(i||s)return i&&(r.get=i),s&&(r.set=s),r}return r.value=t[n],r}}Object.getOwnPropertyNames||(Object.getOwnPropertyNames=function(t){return Object.keys(t)});if(!Object.create){var b;Object.prototype.__proto__===null?b=function(){return{__proto__:null}}:b=function(){var e={};for(var t in e)e[t]=null;return e.constructor=e.hasOwnProperty=e.propertyIsEnumerable=e.isPrototypeOf=e.toLocaleString=e.toString=e.valueOf=e.__proto__=null,e},Object.create=function(t,n){var r;if(t===null)r=b();else{if(typeof t!=\"object\")throw new TypeError(\"typeof prototype[\"+typeof t+\"] != 'object'\");var i=function(){};i.prototype=t,r=new i,r.__proto__=t}return n!==void 0&&Object.defineProperties(r,n),r}}if(Object.defineProperty){var E=w({}),S=typeof document==\"undefined\"||w(document.createElement(\"div\"));if(!E||!S)var x=Object.defineProperty}if(!Object.defineProperty||x){var T=\"Property description must be an object: \",N=\"Object.defineProperty called on non-object: \",C=\"getters & setters can not be defined on this javascript engine\";Object.defineProperty=function(t,n,r){if(typeof t!=\"object\"&&typeof t!=\"function\"||t===null)throw new TypeError(N+t);if(typeof r!=\"object\"&&typeof r!=\"function\"||r===null)throw new TypeError(T+r);if(x)try{return x.call(Object,t,n,r)}catch(i){}if(f(r,\"value\"))if(d&&(h(t,n)||p(t,n))){var s=t.__proto__;t.__proto__=o,delete t[n],t[n]=r.value,t.__proto__=s}else t[n]=r.value;else{if(!d)throw new TypeError(C);f(r,\"get\")&&l(t,n,r.get),f(r,\"set\")&&c(t,n,r.set)}return t}}Object.defineProperties||(Object.defineProperties=function(t,n){for(var r in n)f(n,r)&&Object.defineProperty(t,r,n[r]);return t}),Object.seal||(Object.seal=function(t){return t}),Object.freeze||(Object.freeze=function(t){return t});try{Object.freeze(function(){})}catch(k){Object.freeze=function(t){return function(n){return typeof n==\"function\"?n:t(n)}}(Object.freeze)}Object.preventExtensions||(Object.preventExtensions=function(t){return t}),Object.isSealed||(Object.isSealed=function(t){return!1}),Object.isFrozen||(Object.isFrozen=function(t){return!1}),Object.isExtensible||(Object.isExtensible=function(t){if(Object(t)===t)throw new TypeError;var n=\"\";while(f(t,n))n+=\"?\";t[n]=!0;var r=f(t,n);return delete t[n],r});if(!Object.keys){var L=!0,A=[\"toString\",\"toLocaleString\",\"valueOf\",\"hasOwnProperty\",\"isPrototypeOf\",\"propertyIsEnumerable\",\"constructor\"],O=A.length;for(var M in{toString:null})L=!1;Object.keys=function I(e){if(typeof e!=\"object\"&&typeof e!=\"function\"||e===null)throw new TypeError(\"Object.keys called on a non-object\");var I=[];for(var t in e)f(e,t)&&I.push(t);if(L)for(var n=0,r=O;n<r;n++){var i=A[n];f(e,i)&&I.push(i)}return I}}Date.now||(Date.now=function(){return(new Date).getTime()});var _=\"\t\\n\u000b\\f\\r \\u00a0\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000\\u2028\\u2029\\ufeff\";if(!String.prototype.trim||_.trim()){_=\"[\"+_+\"]\";var D=new RegExp(\"^\"+_+_+\"*\"),P=new RegExp(_+_+\"*$\");String.prototype.trim=function(){return String(this).replace(D,\"\").replace(P,\"\")}}var F=function(e){if(e==null)throw new TypeError(\"can't convert \"+e+\" to object\");return Object(e)}}),define(\"ace/lib/fixoldbrowsers\",[\"require\",\"exports\",\"module\",\"ace/lib/regexp\",\"ace/lib/es5-shim\"],function(e,t,n){\"use strict\";e(\"./regexp\"),e(\"./es5-shim\")}),define(\"ace/lib/dom\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r=\"http://www.w3.org/1999/xhtml\";t.getDocumentHead=function(e){return e||(e=document),e.head||e.getElementsByTagName(\"head\")[0]||e.documentElement},t.createElement=function(e,t){return document.createElementNS?document.createElementNS(t||r,e):document.createElement(e)},t.hasCssClass=function(e,t){var n=(e.className||\"\").split(/\\s+/g);return n.indexOf(t)!==-1},t.addCssClass=function(e,n){t.hasCssClass(e,n)||(e.className+=\" \"+n)},t.removeCssClass=function(e,t){var n=e.className.split(/\\s+/g);for(;;){var r=n.indexOf(t);if(r==-1)break;n.splice(r,1)}e.className=n.join(\" \")},t.toggleCssClass=function(e,t){var n=e.className.split(/\\s+/g),r=!0;for(;;){var i=n.indexOf(t);if(i==-1)break;r=!1,n.splice(i,1)}return r&&n.push(t),e.className=n.join(\" \"),r},t.setCssClass=function(e,n,r){r?t.addCssClass(e,n):t.removeCssClass(e,n)},t.hasCssString=function(e,t){var n=0,r;t=t||document;if(t.createStyleSheet&&(r=t.styleSheets)){while(n<r.length)if(r[n++].owningElement.id===e)return!0}else if(r=t.getElementsByTagName(\"style\"))while(n<r.length)if(r[n++].id===e)return!0;return!1},t.importCssString=function(n,i,s){s=s||document;if(i&&t.hasCssString(i,s))return null;var o;i&&(n+=\"\\n/*# sourceURL=ace/css/\"+i+\" */\"),s.createStyleSheet?(o=s.createStyleSheet(),o.cssText=n,i&&(o.owningElement.id=i)):(o=s.createElementNS?s.createElementNS(r,\"style\"):s.createElement(\"style\"),o.appendChild(s.createTextNode(n)),i&&(o.id=i),t.getDocumentHead(s).appendChild(o))},t.importCssStylsheet=function(e,n){if(n.createStyleSheet)n.createStyleSheet(e);else{var r=t.createElement(\"link\");r.rel=\"stylesheet\",r.href=e,t.getDocumentHead(n).appendChild(r)}},t.getInnerWidth=function(e){return parseInt(t.computedStyle(e,\"paddingLeft\"),10)+parseInt(t.computedStyle(e,\"paddingRight\"),10)+e.clientWidth},t.getInnerHeight=function(e){return parseInt(t.computedStyle(e,\"paddingTop\"),10)+parseInt(t.computedStyle(e,\"paddingBottom\"),10)+e.clientHeight},t.scrollbarWidth=function(e){var n=t.createElement(\"ace_inner\");n.style.width=\"100%\",n.style.minWidth=\"0px\",n.style.height=\"200px\",n.style.display=\"block\";var r=t.createElement(\"ace_outer\"),i=r.style;i.position=\"absolute\",i.left=\"-10000px\",i.overflow=\"hidden\",i.width=\"200px\",i.minWidth=\"0px\",i.height=\"150px\",i.display=\"block\",r.appendChild(n);var s=e.documentElement;s.appendChild(r);var o=n.offsetWidth;i.overflow=\"scroll\";var u=n.offsetWidth;return o==u&&(u=r.clientWidth),s.removeChild(r),o-u};if(typeof document==\"undefined\"){t.importCssString=function(){};return}window.pageYOffset!==undefined?(t.getPageScrollTop=function(){return window.pageYOffset},t.getPageScrollLeft=function(){return window.pageXOffset}):(t.getPageScrollTop=function(){return document.body.scrollTop},t.getPageScrollLeft=function(){return document.body.scrollLeft}),window.getComputedStyle?t.computedStyle=function(e,t){return t?(window.getComputedStyle(e,\"\")||{})[t]||\"\":window.getComputedStyle(e,\"\")||{}}:t.computedStyle=function(e,t){return t?e.currentStyle[t]:e.currentStyle},t.setInnerHtml=function(e,t){var n=e.cloneNode(!1);return n.innerHTML=t,e.parentNode.replaceChild(n,e),n},\"textContent\"in document.documentElement?(t.setInnerText=function(e,t){e.textContent=t},t.getInnerText=function(e){return e.textContent}):(t.setInnerText=function(e,t){e.innerText=t},t.getInnerText=function(e){return e.innerText}),t.getParentWindow=function(e){return e.defaultView||e.parentWindow}}),define(\"ace/lib/oop\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";t.inherits=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})},t.mixin=function(e,t){for(var n in t)e[n]=t[n];return e},t.implement=function(e,n){t.mixin(e,n)}}),define(\"ace/lib/keys\",[\"require\",\"exports\",\"module\",\"ace/lib/fixoldbrowsers\",\"ace/lib/oop\"],function(e,t,n){\"use strict\";e(\"./fixoldbrowsers\");var r=e(\"./oop\"),i=function(){var e={MODIFIER_KEYS:{16:\"Shift\",17:\"Ctrl\",18:\"Alt\",224:\"Meta\"},KEY_MODS:{ctrl:1,alt:2,option:2,shift:4,\"super\":8,meta:8,command:8,cmd:8},FUNCTION_KEYS:{8:\"Backspace\",9:\"Tab\",13:\"Return\",19:\"Pause\",27:\"Esc\",32:\"Space\",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"Left\",38:\"Up\",39:\"Right\",40:\"Down\",44:\"Print\",45:\"Insert\",46:\"Delete\",96:\"Numpad0\",97:\"Numpad1\",98:\"Numpad2\",99:\"Numpad3\",100:\"Numpad4\",101:\"Numpad5\",102:\"Numpad6\",103:\"Numpad7\",104:\"Numpad8\",105:\"Numpad9\",\"-13\":\"NumpadEnter\",112:\"F1\",113:\"F2\",114:\"F3\",115:\"F4\",116:\"F5\",117:\"F6\",118:\"F7\",119:\"F8\",120:\"F9\",121:\"F10\",122:\"F11\",123:\"F12\",144:\"Numlock\",145:\"Scrolllock\"},PRINTABLE_KEYS:{32:\" \",48:\"0\",49:\"1\",50:\"2\",51:\"3\",52:\"4\",53:\"5\",54:\"6\",55:\"7\",56:\"8\",57:\"9\",59:\";\",61:\"=\",65:\"a\",66:\"b\",67:\"c\",68:\"d\",69:\"e\",70:\"f\",71:\"g\",72:\"h\",73:\"i\",74:\"j\",75:\"k\",76:\"l\",77:\"m\",78:\"n\",79:\"o\",80:\"p\",81:\"q\",82:\"r\",83:\"s\",84:\"t\",85:\"u\",86:\"v\",87:\"w\",88:\"x\",89:\"y\",90:\"z\",107:\"+\",109:\"-\",110:\".\",186:\";\",187:\"=\",188:\",\",189:\"-\",190:\".\",191:\"/\",192:\"`\",219:\"[\",220:\"\\\\\",221:\"]\",222:\"'\"}},t,n;for(n in e.FUNCTION_KEYS)t=e.FUNCTION_KEYS[n].toLowerCase(),e[t]=parseInt(n,10);for(n in e.PRINTABLE_KEYS)t=e.PRINTABLE_KEYS[n].toLowerCase(),e[t]=parseInt(n,10);return r.mixin(e,e.MODIFIER_KEYS),r.mixin(e,e.PRINTABLE_KEYS),r.mixin(e,e.FUNCTION_KEYS),e.enter=e[\"return\"],e.escape=e.esc,e.del=e[\"delete\"],e[173]=\"-\",function(){var t=[\"cmd\",\"ctrl\",\"alt\",\"shift\"];for(var n=Math.pow(2,t.length);n--;)e.KEY_MODS[n]=t.filter(function(t){return n&e.KEY_MODS[t]}).join(\"-\")+\"-\"}(),e.KEY_MODS[0]=\"\",e.KEY_MODS[-1]=\"input-\",e}();r.mixin(t,i),t.keyCodeToString=function(e){var t=i[e];return typeof t!=\"string\"&&(t=String.fromCharCode(e)),t.toLowerCase()}}),define(\"ace/lib/useragent\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";t.OS={LINUX:\"LINUX\",MAC:\"MAC\",WINDOWS:\"WINDOWS\"},t.getOS=function(){return t.isMac?t.OS.MAC:t.isLinux?t.OS.LINUX:t.OS.WINDOWS};if(typeof navigator!=\"object\")return;var r=(navigator.platform.match(/mac|win|linux/i)||[\"other\"])[0].toLowerCase(),i=navigator.userAgent;t.isWin=r==\"win\",t.isMac=r==\"mac\",t.isLinux=r==\"linux\",t.isIE=navigator.appName==\"Microsoft Internet Explorer\"||navigator.appName.indexOf(\"MSAppHost\")>=0?parseFloat((i.match(/(?:MSIE |Trident\\/[0-9]+[\\.0-9]+;.*rv:)([0-9]+[\\.0-9]+)/)||[])[1]):parseFloat((i.match(/(?:Trident\\/[0-9]+[\\.0-9]+;.*rv:)([0-9]+[\\.0-9]+)/)||[])[1]),t.isOldIE=t.isIE&&t.isIE<9,t.isGecko=t.isMozilla=(window.Controllers||window.controllers)&&window.navigator.product===\"Gecko\",t.isOldGecko=t.isGecko&&parseInt((i.match(/rv\\:(\\d+)/)||[])[1],10)<4,t.isOpera=window.opera&&Object.prototype.toString.call(window.opera)==\"[object Opera]\",t.isWebKit=parseFloat(i.split(\"WebKit/\")[1])||undefined,t.isChrome=parseFloat(i.split(\" Chrome/\")[1])||undefined,t.isAIR=i.indexOf(\"AdobeAIR\")>=0,t.isIPad=i.indexOf(\"iPad\")>=0,t.isTouchPad=i.indexOf(\"TouchPad\")>=0,t.isChromeOS=i.indexOf(\" CrOS \")>=0}),define(\"ace/lib/event\",[\"require\",\"exports\",\"module\",\"ace/lib/keys\",\"ace/lib/useragent\"],function(e,t,n){\"use strict\";function o(e,t,n){var o=s(t);if(!i.isMac&&u){if(u[91]||u[92])o|=8;if(u.altGr){if((3&o)==3)return;u.altGr=0}if(n===18||n===17){var f=\"location\"in t?t.location:t.keyLocation;if(n===17&&f===1)u[n]==1&&(a=t.timeStamp);else if(n===18&&o===3&&f===2){var l=t.timeStamp-a;l<50&&(u.altGr=!0)}}}n in r.MODIFIER_KEYS&&(n=-1),o&8&&(n===91||n===93)&&(n=-1);if(!o&&n===13){var f=\"location\"in t?t.location:t.keyLocation;if(f===3){e(t,o,-n);if(t.defaultPrevented)return}}if(i.isChromeOS&&o&8){e(t,o,n);if(t.defaultPrevented)return;o&=-9}return!!o||n in r.FUNCTION_KEYS||n in r.PRINTABLE_KEYS?e(t,o,n):!1}function f(e){u=Object.create(null)}var r=e(\"./keys\"),i=e(\"./useragent\");t.addListener=function(e,t,n){if(e.addEventListener)return e.addEventListener(t,n,!1);if(e.attachEvent){var r=function(){n.call(e,window.event)};n._wrapper=r,e.attachEvent(\"on\"+t,r)}},t.removeListener=function(e,t,n){if(e.removeEventListener)return e.removeEventListener(t,n,!1);e.detachEvent&&e.detachEvent(\"on\"+t,n._wrapper||n)},t.stopEvent=function(e){return t.stopPropagation(e),t.preventDefault(e),!1},t.stopPropagation=function(e){e.stopPropagation?e.stopPropagation():e.cancelBubble=!0},t.preventDefault=function(e){e.preventDefault?e.preventDefault():e.returnValue=!1},t.getButton=function(e){return e.type==\"dblclick\"?0:e.type==\"contextmenu\"||i.isMac&&e.ctrlKey&&!e.altKey&&!e.shiftKey?2:e.preventDefault?e.button:{1:0,2:2,4:1}[e.button]},t.capture=function(e,n,r){function i(e){n&&n(e),r&&r(e),t.removeListener(document,\"mousemove\",n,!0),t.removeListener(document,\"mouseup\",i,!0),t.removeListener(document,\"dragstart\",i,!0)}return t.addListener(document,\"mousemove\",n,!0),t.addListener(document,\"mouseup\",i,!0),t.addListener(document,\"dragstart\",i,!0),i},t.addTouchMoveListener=function(e,n){if(\"ontouchmove\"in e){var r,i;t.addListener(e,\"touchstart\",function(e){var t=e.changedTouches[0];r=t.clientX,i=t.clientY}),t.addListener(e,\"touchmove\",function(e){var t=1,s=e.changedTouches[0];e.wheelX=-(s.clientX-r)/t,e.wheelY=-(s.clientY-i)/t,r=s.clientX,i=s.clientY,n(e)})}},t.addMouseWheelListener=function(e,n){\"onmousewheel\"in e?t.addListener(e,\"mousewheel\",function(e){var t=8;e.wheelDeltaX!==undefined?(e.wheelX=-e.wheelDeltaX/t,e.wheelY=-e.wheelDeltaY/t):(e.wheelX=0,e.wheelY=-e.wheelDelta/t),n(e)}):\"onwheel\"in e?t.addListener(e,\"wheel\",function(e){var t=.35;switch(e.deltaMode){case e.DOM_DELTA_PIXEL:e.wheelX=e.deltaX*t||0,e.wheelY=e.deltaY*t||0;break;case e.DOM_DELTA_LINE:case e.DOM_DELTA_PAGE:e.wheelX=(e.deltaX||0)*5,e.wheelY=(e.deltaY||0)*5}n(e)}):t.addListener(e,\"DOMMouseScroll\",function(e){e.axis&&e.axis==e.HORIZONTAL_AXIS?(e.wheelX=(e.detail||0)*5,e.wheelY=0):(e.wheelX=0,e.wheelY=(e.detail||0)*5),n(e)})},t.addMultiMouseDownListener=function(e,n,r,s){var o=0,u,a,f,l={2:\"dblclick\",3:\"tripleclick\",4:\"quadclick\"};t.addListener(e,\"mousedown\",function(e){t.getButton(e)!==0?o=0:e.detail>1?(o++,o>4&&(o=1)):o=1;if(i.isIE){var c=Math.abs(e.clientX-u)>5||Math.abs(e.clientY-a)>5;if(!f||c)o=1;f&&clearTimeout(f),f=setTimeout(function(){f=null},n[o-1]||600),o==1&&(u=e.clientX,a=e.clientY)}e._clicks=o,r[s](\"mousedown\",e);if(o>4)o=0;else if(o>1)return r[s](l[o],e)}),i.isOldIE&&t.addListener(e,\"dblclick\",function(e){o=2,f&&clearTimeout(f),f=setTimeout(function(){f=null},n[o-1]||600),r[s](\"mousedown\",e),r[s](l[o],e)})};var s=!i.isMac||!i.isOpera||\"KeyboardEvent\"in window?function(e){return 0|(e.ctrlKey?1:0)|(e.altKey?2:0)|(e.shiftKey?4:0)|(e.metaKey?8:0)}:function(e){return 0|(e.metaKey?1:0)|(e.altKey?2:0)|(e.shiftKey?4:0)|(e.ctrlKey?8:0)};t.getModifierString=function(e){return r.KEY_MODS[s(e)]};var u=null,a=0;t.addCommandKeyListener=function(e,n){var r=t.addListener;if(i.isOldGecko||i.isOpera&&!(\"KeyboardEvent\"in window)){var s=null;r(e,\"keydown\",function(e){s=e.keyCode}),r(e,\"keypress\",function(e){return o(n,e,s)})}else{var a=null;r(e,\"keydown\",function(e){u[e.keyCode]=(u[e.keyCode]||0)+1;var t=o(n,e,e.keyCode);return a=e.defaultPrevented,t}),r(e,\"keypress\",function(e){a&&(e.ctrlKey||e.altKey||e.shiftKey||e.metaKey)&&(t.stopEvent(e),a=null)}),r(e,\"keyup\",function(e){u[e.keyCode]=null}),u||(f(),r(window,\"focus\",f))}};if(typeof window==\"object\"&&window.postMessage&&!i.isOldIE){var l=1;t.nextTick=function(e,n){n=n||window;var r=\"zero-timeout-message-\"+l;t.addListener(n,\"message\",function i(s){s.data==r&&(t.stopPropagation(s),t.removeListener(n,\"message\",i),e())}),n.postMessage(r,\"*\")}}t.nextFrame=typeof window==\"object\"&&(window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame||window.oRequestAnimationFrame),t.nextFrame?t.nextFrame=t.nextFrame.bind(window):t.nextFrame=function(e){setTimeout(e,17)}}),define(\"ace/lib/lang\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";t.last=function(e){return e[e.length-1]},t.stringReverse=function(e){return e.split(\"\").reverse().join(\"\")},t.stringRepeat=function(e,t){var n=\"\";while(t>0){t&1&&(n+=e);if(t>>=1)e+=e}return n};var r=/^\\s\\s*/,i=/\\s\\s*$/;t.stringTrimLeft=function(e){return e.replace(r,\"\")},t.stringTrimRight=function(e){return e.replace(i,\"\")},t.copyObject=function(e){var t={};for(var n in e)t[n]=e[n];return t},t.copyArray=function(e){var t=[];for(var n=0,r=e.length;n<r;n++)e[n]&&typeof e[n]==\"object\"?t[n]=this.copyObject(e[n]):t[n]=e[n];return t},t.deepCopy=function s(e){if(typeof e!=\"object\"||!e)return e;var t;if(Array.isArray(e)){t=[];for(var n=0;n<e.length;n++)t[n]=s(e[n]);return t}var r=e.constructor;if(r===RegExp)return e;t=r();for(var n in e)t[n]=s(e[n]);return t},t.arrayToMap=function(e){var t={};for(var n=0;n<e.length;n++)t[e[n]]=1;return t},t.createMap=function(e){var t=Object.create(null);for(var n in e)t[n]=e[n];return t},t.arrayRemove=function(e,t){for(var n=0;n<=e.length;n++)t===e[n]&&e.splice(n,1)},t.escapeRegExp=function(e){return e.replace(/([.*+?^${}()|[\\]\\/\\\\])/g,\"\\\\$1\")},t.escapeHTML=function(e){return e.replace(/&/g,\"&#38;\").replace(/\"/g,\"&#34;\").replace(/'/g,\"&#39;\").replace(/</g,\"&#60;\")},t.getMatchOffsets=function(e,t){var n=[];return e.replace(t,function(e){n.push({offset:arguments[arguments.length-2],length:e.length})}),n},t.deferredCall=function(e){var t=null,n=function(){t=null,e()},r=function(e){return r.cancel(),t=setTimeout(n,e||0),r};return r.schedule=r,r.call=function(){return this.cancel(),e(),r},r.cancel=function(){return clearTimeout(t),t=null,r},r.isPending=function(){return t},r},t.delayedCall=function(e,t){var n=null,r=function(){n=null,e()},i=function(e){n==null&&(n=setTimeout(r,e||t))};return i.delay=function(e){n&&clearTimeout(n),n=setTimeout(r,e||t)},i.schedule=i,i.call=function(){this.cancel(),e()},i.cancel=function(){n&&clearTimeout(n),n=null},i.isPending=function(){return n},i}}),define(\"ace/keyboard/textinput\",[\"require\",\"exports\",\"module\",\"ace/lib/event\",\"ace/lib/useragent\",\"ace/lib/dom\",\"ace/lib/lang\"],function(e,t,n){\"use strict\";var r=e(\"../lib/event\"),i=e(\"../lib/useragent\"),s=e(\"../lib/dom\"),o=e(\"../lib/lang\"),u=i.isChrome<18,a=i.isIE,f=function(e,t){function b(e){if(h)return;h=!0;if(k)t=0,r=e?0:n.value.length-1;else var t=e?2:1,r=2;try{n.setSelectionRange(t,r)}catch(i){}h=!1}function w(){if(h)return;n.value=f,i.isWebKit&&y.schedule()}function R(){clearTimeout(q),q=setTimeout(function(){p&&(n.style.cssText=p,p=\"\"),t.renderer.$keepTextAreaAtCursor==null&&(t.renderer.$keepTextAreaAtCursor=!0,t.renderer.$moveTextAreaToCursor())},i.isOldIE?200:0)}var n=s.createElement(\"textarea\");n.className=\"ace_text-input\",i.isTouchPad&&n.setAttribute(\"x-palm-disable-auto-cap\",!0),n.setAttribute(\"wrap\",\"off\"),n.setAttribute(\"autocorrect\",\"off\"),n.setAttribute(\"autocapitalize\",\"off\"),n.setAttribute(\"spellcheck\",!1),n.style.opacity=\"0\",i.isOldIE&&(n.style.top=\"-1000px\"),e.insertBefore(n,e.firstChild);var f=\"\u0001\u0001\",l=!1,c=!1,h=!1,p=\"\",d=!0;try{var v=document.activeElement===n}catch(m){}r.addListener(n,\"blur\",function(e){t.onBlur(e),v=!1}),r.addListener(n,\"focus\",function(e){v=!0,t.onFocus(e),b()}),this.focus=function(){if(p)return n.focus();var e=n.style.top;n.style.position=\"fixed\",n.style.top=\"-1000px\",n.focus(),setTimeout(function(){n.style.position=\"\",n.style.top==\"-1000px\"&&(n.style.top=e)},0)},this.blur=function(){n.blur()},this.isFocused=function(){return v};var g=o.delayedCall(function(){v&&b(d)}),y=o.delayedCall(function(){h||(n.value=f,v&&b())});i.isWebKit||t.addEventListener(\"changeSelection\",function(){t.selection.isEmpty()!=d&&(d=!d,g.schedule())}),w(),v&&t.onFocus();var E=function(e){return e.selectionStart===0&&e.selectionEnd===e.value.length};!n.setSelectionRange&&n.createTextRange&&(n.setSelectionRange=function(e,t){var n=this.createTextRange();n.collapse(!0),n.moveStart(\"character\",e),n.moveEnd(\"character\",t),n.select()},E=function(e){try{var t=e.ownerDocument.selection.createRange()}catch(n){}return!t||t.parentElement()!=e?!1:t.text==e.value});if(i.isOldIE){var S=!1,x=function(e){if(S)return;var t=n.value;if(h||!t||t==f)return;if(e&&t==f[0])return T.schedule();A(t),S=!0,w(),S=!1},T=o.delayedCall(x);r.addListener(n,\"propertychange\",x);var N={13:1,27:1};r.addListener(n,\"keyup\",function(e){h&&(!n.value||N[e.keyCode])&&setTimeout(F,0);if((n.value.charCodeAt(0)||0)<129)return T.call();h?j():B()}),r.addListener(n,\"keydown\",function(e){T.schedule(50)})}var C=function(e){l?l=!1:E(n)?(t.selectAll(),b()):k&&b(t.selection.isEmpty())},k=null;this.setInputHandler=function(e){k=e},this.getInputHandler=function(){return k};var L=!1,A=function(e){k&&(e=k(e),k=null),c?(b(),e&&t.onPaste(e),c=!1):e==f.charAt(0)?L?t.execCommand(\"del\",{source:\"ace\"}):t.execCommand(\"backspace\",{source:\"ace\"}):(e.substring(0,2)==f?e=e.substr(2):e.charAt(0)==f.charAt(0)?e=e.substr(1):e.charAt(e.length-1)==f.charAt(0)&&(e=e.slice(0,-1)),e.charAt(e.length-1)==f.charAt(0)&&(e=e.slice(0,-1)),e&&t.onTextInput(e)),L&&(L=!1)},O=function(e){if(h)return;var t=n.value;A(t),w()},M=function(e,t){var n=e.clipboardData||window.clipboardData;if(!n||u)return;var r=a?\"Text\":\"text/plain\";return t?n.setData(r,t)!==!1:n.getData(r)},_=function(e,i){var s=t.getCopyText();if(!s)return r.preventDefault(e);M(e,s)?(i?t.onCut():t.onCopy(),r.preventDefault(e)):(l=!0,n.value=s,n.select(),setTimeout(function(){l=!1,w(),b(),i?t.onCut():t.onCopy()}))},D=function(e){_(e,!0)},P=function(e){_(e,!1)},H=function(e){var s=M(e);typeof s==\"string\"?(s&&t.onPaste(s,e),i.isIE&&setTimeout(b),r.preventDefault(e)):(n.value=\"\",c=!0)};r.addCommandKeyListener(n,t.onCommandKey.bind(t)),r.addListener(n,\"select\",C),r.addListener(n,\"input\",O),r.addListener(n,\"cut\",D),r.addListener(n,\"copy\",P),r.addListener(n,\"paste\",H),(!(\"oncut\"in n)||!(\"oncopy\"in n)||!(\"onpaste\"in n))&&r.addListener(e,\"keydown\",function(e){if(i.isMac&&!e.metaKey||!e.ctrlKey)return;switch(e.keyCode){case 67:P(e);break;case 86:H(e);break;case 88:D(e)}});var B=function(e){if(h||!t.onCompositionStart||t.$readOnly)return;h={},t.onCompositionStart(),setTimeout(j,0),t.on(\"mousedown\",F),t.selection.isEmpty()||(t.insert(\"\"),t.session.markUndoGroup(),t.selection.clearSelection()),t.session.markUndoGroup()},j=function(){if(!h||!t.onCompositionUpdate||t.$readOnly)return;var e=n.value.replace(/\\x01/g,\"\");if(h.lastValue===e)return;t.onCompositionUpdate(e),h.lastValue&&t.undo(),h.lastValue=e;if(h.lastValue){var r=t.selection.getRange();t.insert(h.lastValue),t.session.markUndoGroup(),h.range=t.selection.getRange(),t.selection.setRange(r),t.selection.clearSelection()}},F=function(e){if(!t.onCompositionEnd||t.$readOnly)return;var r=h;h=!1;var i=setTimeout(function(){i=null;var e=n.value.replace(/\\x01/g,\"\");if(h)return;e==r.lastValue?w():!r.lastValue&&e&&(w(),A(e))});k=function(n){return i&&clearTimeout(i),n=n.replace(/\\x01/g,\"\"),n==r.lastValue?\"\":(r.lastValue&&i&&t.undo(),n)},t.onCompositionEnd(),t.removeListener(\"mousedown\",F),e.type==\"compositionend\"&&r.range&&t.selection.setRange(r.range)},I=o.delayedCall(j,50);r.addListener(n,\"compositionstart\",B),i.isGecko?r.addListener(n,\"text\",function(){I.schedule()}):(r.addListener(n,\"keyup\",function(){I.schedule()}),r.addListener(n,\"keydown\",function(){I.schedule()})),r.addListener(n,\"compositionend\",F),this.getElement=function(){return n},this.setReadOnly=function(e){n.readOnly=e},this.onContextMenu=function(e){L=!0,b(t.selection.isEmpty()),t._emit(\"nativecontextmenu\",{target:t,domEvent:e}),this.moveToMouse(e,!0)},this.moveToMouse=function(e,o){if(!o&&i.isOldIE)return;p||(p=n.style.cssText),n.style.cssText=(o?\"z-index:100000;\":\"\")+\"height:\"+n.style.height+\";\"+(i.isIE?\"opacity:0.1;\":\"\");var u=t.container.getBoundingClientRect(),a=s.computedStyle(t.container),f=u.top+(parseInt(a.borderTopWidth)||0),l=u.left+(parseInt(u.borderLeftWidth)||0),c=u.bottom-f-n.clientHeight-2,h=function(e){n.style.left=e.clientX-l-2+\"px\",n.style.top=Math.min(e.clientY-f-2,c)+\"px\"};h(e);if(e.type!=\"mousedown\")return;t.renderer.$keepTextAreaAtCursor&&(t.renderer.$keepTextAreaAtCursor=null),i.isWin&&!i.isOldIE&&r.capture(t.container,h,R)},this.onContextMenuClose=R;var q,U=function(e){t.textInput.onContextMenu(e),R()};r.addListener(t.renderer.scroller,\"contextmenu\",U),r.addListener(n,\"contextmenu\",U)};t.TextInput=f}),define(\"ace/mouse/default_handlers\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\",\"ace/lib/event\",\"ace/lib/useragent\"],function(e,t,n){\"use strict\";function u(e){e.$clickSelection=null;var t=e.editor;t.setDefaultHandler(\"mousedown\",this.onMouseDown.bind(e)),t.setDefaultHandler(\"dblclick\",this.onDoubleClick.bind(e)),t.setDefaultHandler(\"tripleclick\",this.onTripleClick.bind(e)),t.setDefaultHandler(\"quadclick\",this.onQuadClick.bind(e)),t.setDefaultHandler(\"mousewheel\",this.onMouseWheel.bind(e)),t.setDefaultHandler(\"touchmove\",this.onTouchMove.bind(e));var n=[\"select\",\"startSelect\",\"selectEnd\",\"selectAllEnd\",\"selectByWordsEnd\",\"selectByLinesEnd\",\"dragWait\",\"dragWaitEnd\",\"focusWait\"];n.forEach(function(t){e[t]=this[t]},this),e.selectByLines=this.extendSelectionBy.bind(e,\"getLineRange\"),e.selectByWords=this.extendSelectionBy.bind(e,\"getWordRange\")}function a(e,t,n,r){return Math.sqrt(Math.pow(n-e,2)+Math.pow(r-t,2))}function f(e,t){if(e.start.row==e.end.row)var n=2*t.column-e.start.column-e.end.column;else if(e.start.row==e.end.row-1&&!e.start.column&&!e.end.column)var n=t.column-4;else var n=2*t.row-e.start.row-e.end.row;return n<0?{cursor:e.start,anchor:e.end}:{cursor:e.end,anchor:e.start}}var r=e(\"../lib/dom\"),i=e(\"../lib/event\"),s=e(\"../lib/useragent\"),o=0;(function(){this.onMouseDown=function(e){var t=e.inSelection(),n=e.getDocumentPosition();this.mousedownEvent=e;var r=this.editor,i=e.getButton();if(i!==0){var s=r.getSelectionRange(),o=s.isEmpty();r.$blockScrolling++,o&&r.selection.moveToPosition(n),r.$blockScrolling--,r.textInput.onContextMenu(e.domEvent);return}this.mousedownEvent.time=Date.now();if(t&&!r.isFocused()){r.focus();if(this.$focusTimout&&!this.$clickSelection&&!r.inMultiSelectMode){this.setState(\"focusWait\"),this.captureMouse(e);return}}return this.captureMouse(e),this.startSelect(n,e.domEvent._clicks>1),e.preventDefault()},this.startSelect=function(e,t){e=e||this.editor.renderer.screenToTextCoordinates(this.x,this.y);var n=this.editor;n.$blockScrolling++,this.mousedownEvent.getShiftKey()?n.selection.selectToPosition(e):t||n.selection.moveToPosition(e),t||this.select(),n.renderer.scroller.setCapture&&n.renderer.scroller.setCapture(),n.setStyle(\"ace_selecting\"),this.setState(\"select\"),n.$blockScrolling--},this.select=function(){var e,t=this.editor,n=t.renderer.screenToTextCoordinates(this.x,this.y);t.$blockScrolling++;if(this.$clickSelection){var r=this.$clickSelection.comparePoint(n);if(r==-1)e=this.$clickSelection.end;else if(r==1)e=this.$clickSelection.start;else{var i=f(this.$clickSelection,n);n=i.cursor,e=i.anchor}t.selection.setSelectionAnchor(e.row,e.column)}t.selection.selectToPosition(n),t.$blockScrolling--,t.renderer.scrollCursorIntoView()},this.extendSelectionBy=function(e){var t,n=this.editor,r=n.renderer.screenToTextCoordinates(this.x,this.y),i=n.selection[e](r.row,r.column);n.$blockScrolling++;if(this.$clickSelection){var s=this.$clickSelection.comparePoint(i.start),o=this.$clickSelection.comparePoint(i.end);if(s==-1&&o<=0){t=this.$clickSelection.end;if(i.end.row!=r.row||i.end.column!=r.column)r=i.start}else if(o==1&&s>=0){t=this.$clickSelection.start;if(i.start.row!=r.row||i.start.column!=r.column)r=i.end}else if(s==-1&&o==1)r=i.end,t=i.start;else{var u=f(this.$clickSelection,r);r=u.cursor,t=u.anchor}n.selection.setSelectionAnchor(t.row,t.column)}n.selection.selectToPosition(r),n.$blockScrolling--,n.renderer.scrollCursorIntoView()},this.selectEnd=this.selectAllEnd=this.selectByWordsEnd=this.selectByLinesEnd=function(){this.$clickSelection=null,this.editor.unsetStyle(\"ace_selecting\"),this.editor.renderer.scroller.releaseCapture&&this.editor.renderer.scroller.releaseCapture()},this.focusWait=function(){var e=a(this.mousedownEvent.x,this.mousedownEvent.y,this.x,this.y),t=Date.now();(e>o||t-this.mousedownEvent.time>this.$focusTimout)&&this.startSelect(this.mousedownEvent.getDocumentPosition())},this.onDoubleClick=function(e){var t=e.getDocumentPosition(),n=this.editor,r=n.session,i=r.getBracketRange(t);i?(i.isEmpty()&&(i.start.column--,i.end.column++),this.setState(\"select\")):(i=n.selection.getWordRange(t.row,t.column),this.setState(\"selectByWords\")),this.$clickSelection=i,this.select()},this.onTripleClick=function(e){var t=e.getDocumentPosition(),n=this.editor;this.setState(\"selectByLines\");var r=n.getSelectionRange();r.isMultiLine()&&r.contains(t.row,t.column)?(this.$clickSelection=n.selection.getLineRange(r.start.row),this.$clickSelection.end=n.selection.getLineRange(r.end.row).end):this.$clickSelection=n.selection.getLineRange(t.row),this.select()},this.onQuadClick=function(e){var t=this.editor;t.selectAll(),this.$clickSelection=t.getSelectionRange(),this.setState(\"selectAll\")},this.onMouseWheel=function(e){if(e.getAccelKey())return;e.getShiftKey()&&e.wheelY&&!e.wheelX&&(e.wheelX=e.wheelY,e.wheelY=0);var t=e.domEvent.timeStamp,n=t-(this.$lastScrollTime||0),r=this.editor,i=r.renderer.isScrollableBy(e.wheelX*e.speed,e.wheelY*e.speed);if(i||n<200)return this.$lastScrollTime=t,r.renderer.scrollBy(e.wheelX*e.speed,e.wheelY*e.speed),e.stop()},this.onTouchMove=function(e){var t=e.domEvent.timeStamp,n=t-(this.$lastScrollTime||0),r=this.editor,i=r.renderer.isScrollableBy(e.wheelX*e.speed,e.wheelY*e.speed);if(i||n<200)return this.$lastScrollTime=t,r.renderer.scrollBy(e.wheelX*e.speed,e.wheelY*e.speed),e.stop()}}).call(u.prototype),t.DefaultHandlers=u}),define(\"ace/tooltip\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\"],function(e,t,n){\"use strict\";function s(e){this.isOpen=!1,this.$element=null,this.$parentNode=e}var r=e(\"./lib/oop\"),i=e(\"./lib/dom\");(function(){this.$init=function(){return this.$element=i.createElement(\"div\"),this.$element.className=\"ace_tooltip\",this.$element.style.display=\"none\",this.$parentNode.appendChild(this.$element),this.$element},this.getElement=function(){return this.$element||this.$init()},this.setText=function(e){i.setInnerText(this.getElement(),e)},this.setHtml=function(e){this.getElement().innerHTML=e},this.setPosition=function(e,t){this.getElement().style.left=e+\"px\",this.getElement().style.top=t+\"px\"},this.setClassName=function(e){i.addCssClass(this.getElement(),e)},this.show=function(e,t,n){e!=null&&this.setText(e),t!=null&&n!=null&&this.setPosition(t,n),this.isOpen||(this.getElement().style.display=\"block\",this.isOpen=!0)},this.hide=function(){this.isOpen&&(this.getElement().style.display=\"none\",this.isOpen=!1)},this.getHeight=function(){return this.getElement().offsetHeight},this.getWidth=function(){return this.getElement().offsetWidth}}).call(s.prototype),t.Tooltip=s}),define(\"ace/mouse/default_gutter_handler\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\",\"ace/lib/oop\",\"ace/lib/event\",\"ace/tooltip\"],function(e,t,n){\"use strict\";function u(e){function l(){var r=u.getDocumentPosition().row,s=n.$annotations[r];if(!s)return c();var o=t.session.getLength();if(r==o){var a=t.renderer.pixelToScreenCoordinates(0,u.y).row,l=u.$pos;if(a>t.session.documentToScreenRow(l.row,l.column))return c()}if(f==s)return;f=s.text.join(\"<br/>\"),i.setHtml(f),i.show(),t.on(\"mousewheel\",c);if(e.$tooltipFollowsMouse)h(u);else{var p=n.$cells[t.session.documentToScreenRow(r,0)].element,d=p.getBoundingClientRect(),v=i.getElement().style;v.left=d.right+\"px\",v.top=d.bottom+\"px\"}}function c(){o&&(o=clearTimeout(o)),f&&(i.hide(),f=null,t.removeEventListener(\"mousewheel\",c))}function h(e){i.setPosition(e.x,e.y)}var t=e.editor,n=t.renderer.$gutterLayer,i=new a(t.container);e.editor.setDefaultHandler(\"guttermousedown\",function(r){if(!t.isFocused()||r.getButton()!=0)return;var i=n.getRegion(r);if(i==\"foldWidgets\")return;var s=r.getDocumentPosition().row,o=t.session.selection;if(r.getShiftKey())o.selectTo(s,0);else{if(r.domEvent.detail==2)return t.selectAll(),r.preventDefault();e.$clickSelection=t.selection.getLineRange(s)}return e.setState(\"selectByLines\"),e.captureMouse(r),r.preventDefault()});var o,u,f;e.editor.setDefaultHandler(\"guttermousemove\",function(t){var n=t.domEvent.target||t.domEvent.srcElement;if(r.hasCssClass(n,\"ace_fold-widget\"))return c();f&&e.$tooltipFollowsMouse&&h(t),u=t;if(o)return;o=setTimeout(function(){o=null,u&&!e.isMousePressed?l():c()},50)}),s.addListener(t.renderer.$gutter,\"mouseout\",function(e){u=null;if(!f||o)return;o=setTimeout(function(){o=null,c()},50)}),t.on(\"changeSession\",c)}function a(e){o.call(this,e)}var r=e(\"../lib/dom\"),i=e(\"../lib/oop\"),s=e(\"../lib/event\"),o=e(\"../tooltip\").Tooltip;i.inherits(a,o),function(){this.setPosition=function(e,t){var n=window.innerWidth||document.documentElement.clientWidth,r=window.innerHeight||document.documentElement.clientHeight,i=this.getWidth(),s=this.getHeight();e+=15,t+=15,e+i>n&&(e-=e+i-n),t+s>r&&(t-=20+s),o.prototype.setPosition.call(this,e,t)}}.call(a.prototype),t.GutterHandler=u}),define(\"ace/mouse/mouse_event\",[\"require\",\"exports\",\"module\",\"ace/lib/event\",\"ace/lib/useragent\"],function(e,t,n){\"use strict\";var r=e(\"../lib/event\"),i=e(\"../lib/useragent\"),s=t.MouseEvent=function(e,t){this.domEvent=e,this.editor=t,this.x=this.clientX=e.clientX,this.y=this.clientY=e.clientY,this.$pos=null,this.$inSelection=null,this.propagationStopped=!1,this.defaultPrevented=!1};(function(){this.stopPropagation=function(){r.stopPropagation(this.domEvent),this.propagationStopped=!0},this.preventDefault=function(){r.preventDefault(this.domEvent),this.defaultPrevented=!0},this.stop=function(){this.stopPropagation(),this.preventDefault()},this.getDocumentPosition=function(){return this.$pos?this.$pos:(this.$pos=this.editor.renderer.screenToTextCoordinates(this.clientX,this.clientY),this.$pos)},this.inSelection=function(){if(this.$inSelection!==null)return this.$inSelection;var e=this.editor,t=e.getSelectionRange();if(t.isEmpty())this.$inSelection=!1;else{var n=this.getDocumentPosition();this.$inSelection=t.contains(n.row,n.column)}return this.$inSelection},this.getButton=function(){return r.getButton(this.domEvent)},this.getShiftKey=function(){return this.domEvent.shiftKey},this.getAccelKey=i.isMac?function(){return this.domEvent.metaKey}:function(){return this.domEvent.ctrlKey}}).call(s.prototype)}),define(\"ace/mouse/dragdrop_handler\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\",\"ace/lib/event\",\"ace/lib/useragent\"],function(e,t,n){\"use strict\";function f(e){function T(e,n){var r=Date.now(),i=!n||e.row!=n.row,s=!n||e.column!=n.column;if(!S||i||s)t.$blockScrolling+=1,t.moveCursorToPosition(e),t.$blockScrolling-=1,S=r,x={x:p,y:d};else{var o=l(x.x,x.y,p,d);o>a?S=null:r-S>=u&&(t.renderer.scrollCursorIntoView(),S=null)}}function N(e,n){var r=Date.now(),i=t.renderer.layerConfig.lineHeight,s=t.renderer.layerConfig.characterWidth,u=t.renderer.scroller.getBoundingClientRect(),a={x:{left:p-u.left,right:u.right-p},y:{top:d-u.top,bottom:u.bottom-d}},f=Math.min(a.x.left,a.x.right),l=Math.min(a.y.top,a.y.bottom),c={row:e.row,column:e.column};f/s<=2&&(c.column+=a.x.left<a.x.right?-3:2),l/i<=1&&(c.row+=a.y.top<a.y.bottom?-1:1);var h=e.row!=c.row,v=e.column!=c.column,m=!n||e.row!=n.row;h||v&&!m?E?r-E>=o&&t.renderer.scrollCursorIntoView(c):E=r:E=null}function C(){var e=g;g=t.renderer.screenToTextCoordinates(p,d),T(g,e),N(g,e)}function k(){m=t.selection.toOrientedRange(),h=t.session.addMarker(m,\"ace_selection\",t.getSelectionStyle()),t.clearSelection(),t.isFocused()&&t.renderer.$cursorLayer.setBlinking(!1),clearInterval(v),C(),v=setInterval(C,20),y=0,i.addListener(document,\"mousemove\",O)}function L(){clearInterval(v),t.session.removeMarker(h),h=null,t.$blockScrolling+=1,t.selection.fromOrientedRange(m),t.$blockScrolling-=1,t.isFocused()&&!w&&t.renderer.$cursorLayer.setBlinking(!t.getReadOnly()),m=null,g=null,y=0,E=null,S=null,i.removeListener(document,\"mousemove\",O)}function O(){A==null&&(A=setTimeout(function(){A!=null&&h&&L()},20))}function M(e){var t=e.types;return!t||Array.prototype.some.call(t,function(e){return e==\"text/plain\"||e==\"Text\"})}function _(e){var t=[\"copy\",\"copymove\",\"all\",\"uninitialized\"],n=[\"move\",\"copymove\",\"linkmove\",\"all\",\"uninitialized\"],r=s.isMac?e.altKey:e.ctrlKey,i=\"uninitialized\";try{i=e.dataTransfer.effectAllowed.toLowerCase()}catch(e){}var o=\"none\";return r&&t.indexOf(i)>=0?o=\"copy\":n.indexOf(i)>=0?o=\"move\":t.indexOf(i)>=0&&(o=\"copy\"),o}var t=e.editor,n=r.createElement(\"img\");n.src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\",s.isOpera&&(n.style.cssText=\"width:1px;height:1px;position:fixed;top:0;left:0;z-index:2147483647;opacity:0;\");var f=[\"dragWait\",\"dragWaitEnd\",\"startDrag\",\"dragReadyEnd\",\"onMouseDrag\"];f.forEach(function(t){e[t]=this[t]},this),t.addEventListener(\"mousedown\",this.onMouseDown.bind(e));var c=t.container,h,p,d,v,m,g,y=0,b,w,E,S,x;this.onDragStart=function(e){if(this.cancelDrag||!c.draggable){var r=this;return setTimeout(function(){r.startSelect(),r.captureMouse(e)},0),e.preventDefault()}m=t.getSelectionRange();var i=e.dataTransfer;i.effectAllowed=t.getReadOnly()?\"copy\":\"copyMove\",s.isOpera&&(t.container.appendChild(n),n.scrollTop=0),i.setDragImage&&i.setDragImage(n,0,0),s.isOpera&&t.container.removeChild(n),i.clearData(),i.setData(\"Text\",t.session.getTextRange()),w=!0,this.setState(\"drag\")},this.onDragEnd=function(e){c.draggable=!1,w=!1,this.setState(null);if(!t.getReadOnly()){var n=e.dataTransfer.dropEffect;!b&&n==\"move\"&&t.session.remove(t.getSelectionRange()),t.renderer.$cursorLayer.setBlinking(!0)}this.editor.unsetStyle(\"ace_dragging\"),this.editor.renderer.setCursorStyle(\"\")},this.onDragEnter=function(e){if(t.getReadOnly()||!M(e.dataTransfer))return;return p=e.clientX,d=e.clientY,h||k(),y++,e.dataTransfer.dropEffect=b=_(e),i.preventDefault(e)},this.onDragOver=function(e){if(t.getReadOnly()||!M(e.dataTransfer))return;return p=e.clientX,d=e.clientY,h||(k(),y++),A!==null&&(A=null),e.dataTransfer.dropEffect=b=_(e),i.preventDefault(e)},this.onDragLeave=function(e){y--;if(y<=0&&h)return L(),b=null,i.preventDefault(e)},this.onDrop=function(e){if(!g)return;var n=e.dataTransfer;if(w)switch(b){case\"move\":m.contains(g.row,g.column)?m={start:g,end:g}:m=t.moveText(m,g);break;case\"copy\":m=t.moveText(m,g,!0)}else{var r=n.getData(\"Text\");m={start:g,end:t.session.insert(g,r)},t.focus(),b=null}return L(),i.preventDefault(e)},i.addListener(c,\"dragstart\",this.onDragStart.bind(e)),i.addListener(c,\"dragend\",this.onDragEnd.bind(e)),i.addListener(c,\"dragenter\",this.onDragEnter.bind(e)),i.addListener(c,\"dragover\",this.onDragOver.bind(e)),i.addListener(c,\"dragleave\",this.onDragLeave.bind(e)),i.addListener(c,\"drop\",this.onDrop.bind(e));var A=null}function l(e,t,n,r){return Math.sqrt(Math.pow(n-e,2)+Math.pow(r-t,2))}var r=e(\"../lib/dom\"),i=e(\"../lib/event\"),s=e(\"../lib/useragent\"),o=200,u=200,a=5;(function(){this.dragWait=function(){var e=Date.now()-this.mousedownEvent.time;e>this.editor.getDragDelay()&&this.startDrag()},this.dragWaitEnd=function(){var e=this.editor.container;e.draggable=!1,this.startSelect(this.mousedownEvent.getDocumentPosition()),this.selectEnd()},this.dragReadyEnd=function(e){this.editor.renderer.$cursorLayer.setBlinking(!this.editor.getReadOnly()),this.editor.unsetStyle(\"ace_dragging\"),this.editor.renderer.setCursorStyle(\"\"),this.dragWaitEnd()},this.startDrag=function(){this.cancelDrag=!1;var e=this.editor,t=e.container;t.draggable=!0,e.renderer.$cursorLayer.setBlinking(!1),e.setStyle(\"ace_dragging\");var n=s.isWin?\"default\":\"move\";e.renderer.setCursorStyle(n),this.setState(\"dragReady\")},this.onMouseDrag=function(e){var t=this.editor.container;if(s.isIE&&this.state==\"dragReady\"){var n=l(this.mousedownEvent.x,this.mousedownEvent.y,this.x,this.y);n>3&&t.dragDrop()}if(this.state===\"dragWait\"){var n=l(this.mousedownEvent.x,this.mousedownEvent.y,this.x,this.y);n>0&&(t.draggable=!1,this.startSelect(this.mousedownEvent.getDocumentPosition()))}},this.onMouseDown=function(e){if(!this.$dragEnabled)return;this.mousedownEvent=e;var t=this.editor,n=e.inSelection(),r=e.getButton(),i=e.domEvent.detail||1;if(i===1&&r===0&&n){if(e.editor.inMultiSelectMode&&(e.getAccelKey()||e.getShiftKey()))return;this.mousedownEvent.time=Date.now();var o=e.domEvent.target||e.domEvent.srcElement;\"unselectable\"in o&&(o.unselectable=\"on\");if(t.getDragDelay()){if(s.isWebKit){this.cancelDrag=!0;var u=t.container;u.draggable=!0}this.setState(\"dragWait\")}else this.startDrag();this.captureMouse(e,this.onMouseDrag.bind(this)),e.defaultPrevented=!0}}}).call(f.prototype),t.DragdropHandler=f}),define(\"ace/lib/net\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\"],function(e,t,n){\"use strict\";var r=e(\"./dom\");t.get=function(e,t){var n=new XMLHttpRequest;n.open(\"GET\",e,!0),n.onreadystatechange=function(){n.readyState===4&&t(n.responseText)},n.send(null)},t.loadScript=function(e,t){var n=r.getDocumentHead(),i=document.createElement(\"script\");i.src=e,n.appendChild(i),i.onload=i.onreadystatechange=function(e,n){if(n||!i.readyState||i.readyState==\"loaded\"||i.readyState==\"complete\")i=i.onload=i.onreadystatechange=null,n||t()}},t.qualifyURL=function(e){var t=document.createElement(\"a\");return t.href=e,t.href}}),define(\"ace/lib/event_emitter\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r={},i=function(){this.propagationStopped=!0},s=function(){this.defaultPrevented=!0};r._emit=r._dispatchEvent=function(e,t){this._eventRegistry||(this._eventRegistry={}),this._defaultHandlers||(this._defaultHandlers={});var n=this._eventRegistry[e]||[],r=this._defaultHandlers[e];if(!n.length&&!r)return;if(typeof t!=\"object\"||!t)t={};t.type||(t.type=e),t.stopPropagation||(t.stopPropagation=i),t.preventDefault||(t.preventDefault=s),n=n.slice();for(var o=0;o<n.length;o++){n[o](t,this);if(t.propagationStopped)break}if(r&&!t.defaultPrevented)return r(t,this)},r._signal=function(e,t){var n=(this._eventRegistry||{})[e];if(!n)return;n=n.slice();for(var r=0;r<n.length;r++)n[r](t,this)},r.once=function(e,t){var n=this;t&&this.addEventListener(e,function r(){n.removeEventListener(e,r),t.apply(null,arguments)})},r.setDefaultHandler=function(e,t){var n=this._defaultHandlers;n||(n=this._defaultHandlers={_disabled_:{}});if(n[e]){var r=n[e],i=n._disabled_[e];i||(n._disabled_[e]=i=[]),i.push(r);var s=i.indexOf(t);s!=-1&&i.splice(s,1)}n[e]=t},r.removeDefaultHandler=function(e,t){var n=this._defaultHandlers;if(!n)return;var r=n._disabled_[e];if(n[e]==t){var i=n[e];r&&this.setDefaultHandler(e,r.pop())}else if(r){var s=r.indexOf(t);s!=-1&&r.splice(s,1)}},r.on=r.addEventListener=function(e,t,n){this._eventRegistry=this._eventRegistry||{};var r=this._eventRegistry[e];return r||(r=this._eventRegistry[e]=[]),r.indexOf(t)==-1&&r[n?\"unshift\":\"push\"](t),t},r.off=r.removeListener=r.removeEventListener=function(e,t){this._eventRegistry=this._eventRegistry||{};var n=this._eventRegistry[e];if(!n)return;var r=n.indexOf(t);r!==-1&&n.splice(r,1)},r.removeAllListeners=function(e){this._eventRegistry&&(this._eventRegistry[e]=[])},t.EventEmitter=r}),define(\"ace/lib/app_config\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/event_emitter\"],function(e,t,n){\"no use strict\";function o(e){typeof console!=\"undefined\"&&console.warn&&console.warn.apply(console,arguments)}function u(e,t){var n=new Error(e);n.data=t,typeof console==\"object\"&&console.error&&console.error(n),setTimeout(function(){throw n})}var r=e(\"./oop\"),i=e(\"./event_emitter\").EventEmitter,s={setOptions:function(e){Object.keys(e).forEach(function(t){this.setOption(t,e[t])},this)},getOptions:function(e){var t={};return e?Array.isArray(e)||(t=e,e=Object.keys(t)):e=Object.keys(this.$options),e.forEach(function(e){t[e]=this.getOption(e)},this),t},setOption:function(e,t){if(this[\"$\"+e]===t)return;var n=this.$options[e];if(!n)return o('misspelled option \"'+e+'\"');if(n.forwardTo)return this[n.forwardTo]&&this[n.forwardTo].setOption(e,t);n.handlesSet||(this[\"$\"+e]=t),n&&n.set&&n.set.call(this,t)},getOption:function(e){var t=this.$options[e];return t?t.forwardTo?this[t.forwardTo]&&this[t.forwardTo].getOption(e):t&&t.get?t.get.call(this):this[\"$\"+e]:o('misspelled option \"'+e+'\"')}},a=function(){this.$defaultOptions={}};(function(){r.implement(this,i),this.defineOptions=function(e,t,n){return e.$options||(this.$defaultOptions[t]=e.$options={}),Object.keys(n).forEach(function(t){var r=n[t];typeof r==\"string\"&&(r={forwardTo:r}),r.name||(r.name=t),e.$options[r.name]=r,\"initialValue\"in r&&(e[\"$\"+r.name]=r.initialValue)}),r.implement(e,s),this},this.resetOptions=function(e){Object.keys(e.$options).forEach(function(t){var n=e.$options[t];\"value\"in n&&e.setOption(t,n.value)})},this.setDefaultValue=function(e,t,n){var r=this.$defaultOptions[e]||(this.$defaultOptions[e]={});r[t]&&(r.forwardTo?this.setDefaultValue(r.forwardTo,t,n):r[t].value=n)},this.setDefaultValues=function(e,t){Object.keys(t).forEach(function(n){this.setDefaultValue(e,n,t[n])},this)},this.warn=o,this.reportError=u}).call(a.prototype),t.AppConfig=a}),define(\"ace/config\",[\"require\",\"exports\",\"module\",\"ace/lib/lang\",\"ace/lib/oop\",\"ace/lib/net\",\"ace/lib/app_config\"],function(e,t,n){\"no use strict\";function f(r){a.packaged=r||e.packaged||n.packaged||u.define&&define.packaged;if(!u.document)return\"\";var i={},s=\"\",o=document.currentScript||document._currentScript,f=o&&o.ownerDocument||document,c=f.getElementsByTagName(\"script\");for(var h=0;h<c.length;h++){var p=c[h],d=p.src||p.getAttribute(\"src\");if(!d)continue;var v=p.attributes;for(var m=0,g=v.length;m<g;m++){var y=v[m];y.name.indexOf(\"data-ace-\")===0&&(i[l(y.name.replace(/^data-ace-/,\"\"))]=y.value)}var b=d.match(/^(.*)\\/ace(\\-\\w+)?\\.js(\\?|$)/);b&&(s=b[1])}s&&(i.base=i.base||s,i.packaged=!0),i.basePath=i.base,i.workerPath=i.workerPath||i.base,i.modePath=i.modePath||i.base,i.themePath=i.themePath||i.base,delete i.base;for(var w in i)typeof i[w]!=\"undefined\"&&t.set(w,i[w])}function l(e){return e.replace(/-(.)/g,function(e,t){return t.toUpperCase()})}var r=e(\"./lib/lang\"),i=e(\"./lib/oop\"),s=e(\"./lib/net\"),o=e(\"./lib/app_config\").AppConfig;n.exports=t=new o;var u=function(){return this||typeof window!=\"undefined\"&&window}(),a={packaged:!1,workerPath:null,modePath:null,themePath:null,basePath:\"\",suffix:\".js\",$moduleUrls:{}};t.get=function(e){if(!a.hasOwnProperty(e))throw new Error(\"Unknown config key: \"+e);return a[e]},t.set=function(e,t){if(!a.hasOwnProperty(e))throw new Error(\"Unknown config key: \"+e);a[e]=t},t.all=function(){return r.copyObject(a)},t.moduleUrl=function(e,t){if(a.$moduleUrls[e])return a.$moduleUrls[e];var n=e.split(\"/\");t=t||n[n.length-2]||\"\";var r=t==\"snippets\"?\"/\":\"-\",i=n[n.length-1];if(t==\"worker\"&&r==\"-\"){var s=new RegExp(\"^\"+t+\"[\\\\-_]|[\\\\-_]\"+t+\"$\",\"g\");i=i.replace(s,\"\")}(!i||i==t)&&n.length>1&&(i=n[n.length-2]);var o=a[t+\"Path\"];return o==null?o=a.basePath:r==\"/\"&&(t=r=\"\"),o&&o.slice(-1)!=\"/\"&&(o+=\"/\"),o+t+r+i+this.get(\"suffix\")},t.setModuleUrl=function(e,t){return a.$moduleUrls[e]=t},t.$loading={},t.loadModule=function(n,r){var i,o;Array.isArray(n)&&(o=n[0],n=n[1]);try{i=e(n)}catch(u){}if(i&&!t.$loading[n])return r&&r(i);t.$loading[n]||(t.$loading[n]=[]),t.$loading[n].push(r);if(t.$loading[n].length>1)return;var a=function(){e([n],function(e){t._emit(\"load.module\",{name:n,module:e});var r=t.$loading[n];t.$loading[n]=null,r.forEach(function(t){t&&t(e)})})};if(!t.get(\"packaged\"))return a();s.loadScript(t.moduleUrl(n,o),a)},t.init=f}),define(\"ace/mouse/mouse_handler\",[\"require\",\"exports\",\"module\",\"ace/lib/event\",\"ace/lib/useragent\",\"ace/mouse/default_handlers\",\"ace/mouse/default_gutter_handler\",\"ace/mouse/mouse_event\",\"ace/mouse/dragdrop_handler\",\"ace/config\"],function(e,t,n){\"use strict\";var r=e(\"../lib/event\"),i=e(\"../lib/useragent\"),s=e(\"./default_handlers\").DefaultHandlers,o=e(\"./default_gutter_handler\").GutterHandler,u=e(\"./mouse_event\").MouseEvent,a=e(\"./dragdrop_handler\").DragdropHandler,f=e(\"../config\"),l=function(e){var t=this;this.editor=e,new s(this),new o(this),new a(this);var n=function(t){(!document.hasFocus||!document.hasFocus())&&window.focus(),e.focus()},u=e.renderer.getMouseEventTarget();r.addListener(u,\"click\",this.onMouseEvent.bind(this,\"click\")),r.addListener(u,\"mousemove\",this.onMouseMove.bind(this,\"mousemove\")),r.addMultiMouseDownListener(u,[400,300,250],this,\"onMouseEvent\"),e.renderer.scrollBarV&&(r.addMultiMouseDownListener(e.renderer.scrollBarV.inner,[400,300,250],this,\"onMouseEvent\"),r.addMultiMouseDownListener(e.renderer.scrollBarH.inner,[400,300,250],this,\"onMouseEvent\"),i.isIE&&(r.addListener(e.renderer.scrollBarV.element,\"mousedown\",n),r.addListener(e.renderer.scrollBarH.element,\"mousedown\",n))),r.addMouseWheelListener(e.container,this.onMouseWheel.bind(this,\"mousewheel\")),r.addTouchMoveListener(e.container,this.onTouchMove.bind(this,\"touchmove\"));var f=e.renderer.$gutter;r.addListener(f,\"mousedown\",this.onMouseEvent.bind(this,\"guttermousedown\")),r.addListener(f,\"click\",this.onMouseEvent.bind(this,\"gutterclick\")),r.addListener(f,\"dblclick\",this.onMouseEvent.bind(this,\"gutterdblclick\")),r.addListener(f,\"mousemove\",this.onMouseEvent.bind(this,\"guttermousemove\")),r.addListener(u,\"mousedown\",n),r.addListener(f,\"mousedown\",function(t){return e.focus(),r.preventDefault(t)}),e.on(\"mousemove\",function(n){if(t.state||t.$dragDelay||!t.$dragEnabled)return;var r=e.renderer.screenToTextCoordinates(n.x,n.y),i=e.session.selection.getRange(),s=e.renderer;!i.isEmpty()&&i.insideStart(r.row,r.column)?s.setCursorStyle(\"default\"):s.setCursorStyle(\"\")})};(function(){this.onMouseEvent=function(e,t){this.editor._emit(e,new u(t,this.editor))},this.onMouseMove=function(e,t){var n=this.editor._eventRegistry&&this.editor._eventRegistry.mousemove;if(!n||!n.length)return;this.editor._emit(e,new u(t,this.editor))},this.onMouseWheel=function(e,t){var n=new u(t,this.editor);n.speed=this.$scrollSpeed*2,n.wheelX=t.wheelX,n.wheelY=t.wheelY,this.editor._emit(e,n)},this.onTouchMove=function(e,t){var n=new u(t,this.editor);n.speed=1,n.wheelX=t.wheelX,n.wheelY=t.wheelY,this.editor._emit(e,n)},this.setState=function(e){this.state=e},this.captureMouse=function(e,t){this.x=e.x,this.y=e.y,this.isMousePressed=!0;var n=this.editor.renderer;n.$keepTextAreaAtCursor&&(n.$keepTextAreaAtCursor=null);var s=this,o=function(e){if(!e)return;if(i.isWebKit&&!e.which&&s.releaseMouse)return s.releaseMouse();s.x=e.clientX,s.y=e.clientY,t&&t(e),s.mouseEvent=new u(e,s.editor),s.$mouseMoved=!0},a=function(e){clearInterval(l),f(),s[s.state+\"End\"]&&s[s.state+\"End\"](e),s.state=\"\",n.$keepTextAreaAtCursor==null&&(n.$keepTextAreaAtCursor=!0,n.$moveTextAreaToCursor()),s.isMousePressed=!1,s.$onCaptureMouseMove=s.releaseMouse=null,e&&s.onMouseEvent(\"mouseup\",e)},f=function(){s[s.state]&&s[s.state](),s.$mouseMoved=!1};if(i.isOldIE&&e.domEvent.type==\"dblclick\")return setTimeout(function(){a(e)});s.$onCaptureMouseMove=o,s.releaseMouse=r.capture(this.editor.container,o,a);var l=setInterval(f,20)},this.releaseMouse=null,this.cancelContextMenu=function(){var e=function(t){if(t&&t.domEvent&&t.domEvent.type!=\"contextmenu\")return;this.editor.off(\"nativecontextmenu\",e),t&&t.domEvent&&r.stopEvent(t.domEvent)}.bind(this);setTimeout(e,10),this.editor.on(\"nativecontextmenu\",e)}}).call(l.prototype),f.defineOptions(l.prototype,\"mouseHandler\",{scrollSpeed:{initialValue:2},dragDelay:{initialValue:i.isMac?150:0},dragEnabled:{initialValue:!0},focusTimout:{initialValue:0},tooltipFollowsMouse:{initialValue:!0}}),t.MouseHandler=l}),define(\"ace/mouse/fold_handler\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";function r(e){e.on(\"click\",function(t){var n=t.getDocumentPosition(),r=e.session,i=r.getFoldAt(n.row,n.column,1);i&&(t.getAccelKey()?r.removeFold(i):r.expandFold(i),t.stop())}),e.on(\"gutterclick\",function(t){var n=e.renderer.$gutterLayer.getRegion(t);if(n==\"foldWidgets\"){var r=t.getDocumentPosition().row,i=e.session;i.foldWidgets&&i.foldWidgets[r]&&e.session.onFoldWidgetClick(r,t),e.isFocused()||e.focus(),t.stop()}}),e.on(\"gutterdblclick\",function(t){var n=e.renderer.$gutterLayer.getRegion(t);if(n==\"foldWidgets\"){var r=t.getDocumentPosition().row,i=e.session,s=i.getParentFoldRangeData(r,!0),o=s.range||s.firstRange;if(o){r=o.start.row;var u=i.getFoldAt(r,i.getLine(r).length,1);u?i.removeFold(u):(i.addFold(\"...\",o),e.renderer.scrollCursorIntoView({row:o.start.row,column:0}))}t.stop()}})}t.FoldHandler=r}),define(\"ace/keyboard/keybinding\",[\"require\",\"exports\",\"module\",\"ace/lib/keys\",\"ace/lib/event\"],function(e,t,n){\"use strict\";var r=e(\"../lib/keys\"),i=e(\"../lib/event\"),s=function(e){this.$editor=e,this.$data={editor:e},this.$handlers=[],this.setDefaultHandler(e.commands)};(function(){this.setDefaultHandler=function(e){this.removeKeyboardHandler(this.$defaultHandler),this.$defaultHandler=e,this.addKeyboardHandler(e,0)},this.setKeyboardHandler=function(e){var t=this.$handlers;if(t[t.length-1]==e)return;while(t[t.length-1]&&t[t.length-1]!=this.$defaultHandler)this.removeKeyboardHandler(t[t.length-1]);this.addKeyboardHandler(e,1)},this.addKeyboardHandler=function(e,t){if(!e)return;typeof e==\"function\"&&!e.handleKeyboard&&(e.handleKeyboard=e);var n=this.$handlers.indexOf(e);n!=-1&&this.$handlers.splice(n,1),t==undefined?this.$handlers.push(e):this.$handlers.splice(t,0,e),n==-1&&e.attach&&e.attach(this.$editor)},this.removeKeyboardHandler=function(e){var t=this.$handlers.indexOf(e);return t==-1?!1:(this.$handlers.splice(t,1),e.detach&&e.detach(this.$editor),!0)},this.getKeyboardHandler=function(){return this.$handlers[this.$handlers.length-1]},this.getStatusText=function(){var e=this.$data,t=e.editor;return this.$handlers.map(function(n){return n.getStatusText&&n.getStatusText(t,e)||\"\"}).filter(Boolean).join(\" \")},this.$callKeyboardHandlers=function(e,t,n,r){var s,o=!1,u=this.$editor.commands;for(var a=this.$handlers.length;a--;){s=this.$handlers[a].handleKeyboard(this.$data,e,t,n,r);if(!s||!s.command)continue;s.command==\"null\"?o=!0:o=u.exec(s.command,this.$editor,s.args,r),o&&r&&e!=-1&&s.passEvent!=1&&s.command.passEvent!=1&&i.stopEvent(r);if(o)break}return o},this.onCommandKey=function(e,t,n){var i=r.keyCodeToString(n);this.$callKeyboardHandlers(t,i,n,e)},this.onTextInput=function(e){var t=this.$callKeyboardHandlers(-1,e);t||this.$editor.commands.exec(\"insertstring\",this.$editor,e)}}).call(s.prototype),t.KeyBinding=s}),define(\"ace/range\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r=function(e,t){return e.row-t.row||e.column-t.column},i=function(e,t,n,r){this.start={row:e,column:t},this.end={row:n,column:r}};(function(){this.isEqual=function(e){return this.start.row===e.start.row&&this.end.row===e.end.row&&this.start.column===e.start.column&&this.end.column===e.end.column},this.toString=function(){return\"Range: [\"+this.start.row+\"/\"+this.start.column+\"] -> [\"+this.end.row+\"/\"+this.end.column+\"]\"},this.contains=function(e,t){return this.compare(e,t)==0},this.compareRange=function(e){var t,n=e.end,r=e.start;return t=this.compare(n.row,n.column),t==1?(t=this.compare(r.row,r.column),t==1?2:t==0?1:0):t==-1?-2:(t=this.compare(r.row,r.column),t==-1?-1:t==1?42:0)},this.comparePoint=function(e){return this.compare(e.row,e.column)},this.containsRange=function(e){return this.comparePoint(e.start)==0&&this.comparePoint(e.end)==0},this.intersects=function(e){var t=this.compareRange(e);return t==-1||t==0||t==1},this.isEnd=function(e,t){return this.end.row==e&&this.end.column==t},this.isStart=function(e,t){return this.start.row==e&&this.start.column==t},this.setStart=function(e,t){typeof e==\"object\"?(this.start.column=e.column,this.start.row=e.row):(this.start.row=e,this.start.column=t)},this.setEnd=function(e,t){typeof e==\"object\"?(this.end.column=e.column,this.end.row=e.row):(this.end.row=e,this.end.column=t)},this.inside=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)||this.isStart(e,t)?!1:!0:!1},this.insideStart=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)?!1:!0:!1},this.insideEnd=function(e,t){return this.compare(e,t)==0?this.isStart(e,t)?!1:!0:!1},this.compare=function(e,t){return!this.isMultiLine()&&e===this.start.row?t<this.start.column?-1:t>this.end.column?1:0:e<this.start.row?-1:e>this.end.row?1:this.start.row===e?t>=this.start.column?0:-1:this.end.row===e?t<=this.end.column?0:1:0},this.compareStart=function(e,t){return this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.compareEnd=function(e,t){return this.end.row==e&&this.end.column==t?1:this.compare(e,t)},this.compareInside=function(e,t){return this.end.row==e&&this.end.column==t?1:this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.clipRows=function(e,t){if(this.end.row>t)var n={row:t+1,column:0};else if(this.end.row<e)var n={row:e,column:0};if(this.start.row>t)var r={row:t+1,column:0};else if(this.start.row<e)var r={row:e,column:0};return i.fromPoints(r||this.start,n||this.end)},this.extend=function(e,t){var n=this.compare(e,t);if(n==0)return this;if(n==-1)var r={row:e,column:t};else var s={row:e,column:t};return i.fromPoints(r||this.start,s||this.end)},this.isEmpty=function(){return this.start.row===this.end.row&&this.start.column===this.end.column},this.isMultiLine=function(){return this.start.row!==this.end.row},this.clone=function(){return i.fromPoints(this.start,this.end)},this.collapseRows=function(){return this.end.column==0?new i(this.start.row,0,Math.max(this.start.row,this.end.row-1),0):new i(this.start.row,0,this.end.row,0)},this.toScreenRange=function(e){var t=e.documentToScreenPosition(this.start),n=e.documentToScreenPosition(this.end);return new i(t.row,t.column,n.row,n.column)},this.moveBy=function(e,t){this.start.row+=e,this.start.column+=t,this.end.row+=e,this.end.column+=t}}).call(i.prototype),i.fromPoints=function(e,t){return new i(e.row,e.column,t.row,t.column)},i.comparePoints=r,i.comparePoints=function(e,t){return e.row-t.row||e.column-t.column},t.Range=i}),define(\"ace/selection\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/lib/event_emitter\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/lang\"),s=e(\"./lib/event_emitter\").EventEmitter,o=e(\"./range\").Range,u=function(e){this.session=e,this.doc=e.getDocument(),this.clearSelection(),this.lead=this.selectionLead=this.doc.createAnchor(0,0),this.anchor=this.selectionAnchor=this.doc.createAnchor(0,0);var t=this;this.lead.on(\"change\",function(e){t._emit(\"changeCursor\"),t.$isEmpty||t._emit(\"changeSelection\"),!t.$keepDesiredColumnOnChange&&e.old.column!=e.value.column&&(t.$desiredColumn=null)}),this.selectionAnchor.on(\"change\",function(){t.$isEmpty||t._emit(\"changeSelection\")})};(function(){r.implement(this,s),this.isEmpty=function(){return this.$isEmpty||this.anchor.row==this.lead.row&&this.anchor.column==this.lead.column},this.isMultiLine=function(){return this.isEmpty()?!1:this.getRange().isMultiLine()},this.getCursor=function(){return this.lead.getPosition()},this.setSelectionAnchor=function(e,t){this.anchor.setPosition(e,t),this.$isEmpty&&(this.$isEmpty=!1,this._emit(\"changeSelection\"))},this.getSelectionAnchor=function(){return this.$isEmpty?this.getSelectionLead():this.anchor.getPosition()},this.getSelectionLead=function(){return this.lead.getPosition()},this.shiftSelection=function(e){if(this.$isEmpty){this.moveCursorTo(this.lead.row,this.lead.column+e);return}var t=this.getSelectionAnchor(),n=this.getSelectionLead(),r=this.isBackwards();(!r||t.column!==0)&&this.setSelectionAnchor(t.row,t.column+e),(r||n.column!==0)&&this.$moveSelection(function(){this.moveCursorTo(n.row,n.column+e)})},this.isBackwards=function(){var e=this.anchor,t=this.lead;return e.row>t.row||e.row==t.row&&e.column>t.column},this.getRange=function(){var e=this.anchor,t=this.lead;return this.isEmpty()?o.fromPoints(t,t):this.isBackwards()?o.fromPoints(t,e):o.fromPoints(e,t)},this.clearSelection=function(){this.$isEmpty||(this.$isEmpty=!0,this._emit(\"changeSelection\"))},this.selectAll=function(){var e=this.doc.getLength()-1;this.setSelectionAnchor(0,0),this.moveCursorTo(e,this.doc.getLine(e).length)},this.setRange=this.setSelectionRange=function(e,t){t?(this.setSelectionAnchor(e.end.row,e.end.column),this.selectTo(e.start.row,e.start.column)):(this.setSelectionAnchor(e.start.row,e.start.column),this.selectTo(e.end.row,e.end.column)),this.getRange().isEmpty()&&(this.$isEmpty=!0),this.$desiredColumn=null},this.$moveSelection=function(e){var t=this.lead;this.$isEmpty&&this.setSelectionAnchor(t.row,t.column),e.call(this)},this.selectTo=function(e,t){this.$moveSelection(function(){this.moveCursorTo(e,t)})},this.selectToPosition=function(e){this.$moveSelection(function(){this.moveCursorToPosition(e)})},this.moveTo=function(e,t){this.clearSelection(),this.moveCursorTo(e,t)},this.moveToPosition=function(e){this.clearSelection(),this.moveCursorToPosition(e)},this.selectUp=function(){this.$moveSelection(this.moveCursorUp)},this.selectDown=function(){this.$moveSelection(this.moveCursorDown)},this.selectRight=function(){this.$moveSelection(this.moveCursorRight)},this.selectLeft=function(){this.$moveSelection(this.moveCursorLeft)},this.selectLineStart=function(){this.$moveSelection(this.moveCursorLineStart)},this.selectLineEnd=function(){this.$moveSelection(this.moveCursorLineEnd)},this.selectFileEnd=function(){this.$moveSelection(this.moveCursorFileEnd)},this.selectFileStart=function(){this.$moveSelection(this.moveCursorFileStart)},this.selectWordRight=function(){this.$moveSelection(this.moveCursorWordRight)},this.selectWordLeft=function(){this.$moveSelection(this.moveCursorWordLeft)},this.getWordRange=function(e,t){if(typeof t==\"undefined\"){var n=e||this.lead;e=n.row,t=n.column}return this.session.getWordRange(e,t)},this.selectWord=function(){this.setSelectionRange(this.getWordRange())},this.selectAWord=function(){var e=this.getCursor(),t=this.session.getAWordRange(e.row,e.column);this.setSelectionRange(t)},this.getLineRange=function(e,t){var n=typeof e==\"number\"?e:this.lead.row,r,i=this.session.getFoldLine(n);return i?(n=i.start.row,r=i.end.row):r=n,t===!0?new o(n,0,r,this.session.getLine(r).length):new o(n,0,r+1,0)},this.selectLine=function(){this.setSelectionRange(this.getLineRange())},this.moveCursorUp=function(){this.moveCursorBy(-1,0)},this.moveCursorDown=function(){this.moveCursorBy(1,0)},this.moveCursorLeft=function(){var e=this.lead.getPosition(),t;if(t=this.session.getFoldAt(e.row,e.column,-1))this.moveCursorTo(t.start.row,t.start.column);else if(e.column===0)e.row>0&&this.moveCursorTo(e.row-1,this.doc.getLine(e.row-1).length);else{var n=this.session.getTabSize();this.session.isTabStop(e)&&this.doc.getLine(e.row).slice(e.column-n,e.column).split(\" \").length-1==n?this.moveCursorBy(0,-n):this.moveCursorBy(0,-1)}},this.moveCursorRight=function(){var e=this.lead.getPosition(),t;if(t=this.session.getFoldAt(e.row,e.column,1))this.moveCursorTo(t.end.row,t.end.column);else if(this.lead.column==this.doc.getLine(this.lead.row).length)this.lead.row<this.doc.getLength()-1&&this.moveCursorTo(this.lead.row+1,0);else{var n=this.session.getTabSize(),e=this.lead;this.session.isTabStop(e)&&this.doc.getLine(e.row).slice(e.column,e.column+n).split(\" \").length-1==n?this.moveCursorBy(0,n):this.moveCursorBy(0,1)}},this.moveCursorLineStart=function(){var e=this.lead.row,t=this.lead.column,n=this.session.documentToScreenRow(e,t),r=this.session.screenToDocumentPosition(n,0),i=this.session.getDisplayLine(e,null,r.row,r.column),s=i.match(/^\\s*/);s[0].length!=t&&!this.session.$useEmacsStyleLineStart&&(r.column+=s[0].length),this.moveCursorToPosition(r)},this.moveCursorLineEnd=function(){var e=this.lead,t=this.session.getDocumentLastRowColumnPosition(e.row,e.column);if(this.lead.column==t.column){var n=this.session.getLine(t.row);if(t.column==n.length){var r=n.search(/\\s+$/);r>0&&(t.column=r)}}this.moveCursorTo(t.row,t.column)},this.moveCursorFileEnd=function(){var e=this.doc.getLength()-1,t=this.doc.getLine(e).length;this.moveCursorTo(e,t)},this.moveCursorFileStart=function(){this.moveCursorTo(0,0)},this.moveCursorLongWordRight=function(){var e=this.lead.row,t=this.lead.column,n=this.doc.getLine(e),r=n.substring(t),i;this.session.nonTokenRe.lastIndex=0,this.session.tokenRe.lastIndex=0;var s=this.session.getFoldAt(e,t,1);if(s){this.moveCursorTo(s.end.row,s.end.column);return}if(i=this.session.nonTokenRe.exec(r))t+=this.session.nonTokenRe.lastIndex,this.session.nonTokenRe.lastIndex=0,r=n.substring(t);if(t>=n.length){this.moveCursorTo(e,n.length),this.moveCursorRight(),e<this.doc.getLength()-1&&this.moveCursorWordRight();return}if(i=this.session.tokenRe.exec(r))t+=this.session.tokenRe.lastIndex,this.session.tokenRe.lastIndex=0;this.moveCursorTo(e,t)},this.moveCursorLongWordLeft=function(){var e=this.lead.row,t=this.lead.column,n;if(n=this.session.getFoldAt(e,t,-1)){this.moveCursorTo(n.start.row,n.start.column);return}var r=this.session.getFoldStringAt(e,t,-1);r==null&&(r=this.doc.getLine(e).substring(0,t));var s=i.stringReverse(r),o;this.session.nonTokenRe.lastIndex=0,this.session.tokenRe.lastIndex=0;if(o=this.session.nonTokenRe.exec(s))t-=this.session.nonTokenRe.lastIndex,s=s.slice(this.session.nonTokenRe.lastIndex),this.session.nonTokenRe.lastIndex=0;if(t<=0){this.moveCursorTo(e,0),this.moveCursorLeft(),e>0&&this.moveCursorWordLeft();return}if(o=this.session.tokenRe.exec(s))t-=this.session.tokenRe.lastIndex,this.session.tokenRe.lastIndex=0;this.moveCursorTo(e,t)},this.$shortWordEndIndex=function(e){var t,n=0,r,i=/\\s/,s=this.session.tokenRe;s.lastIndex=0;if(t=this.session.tokenRe.exec(e))n=this.session.tokenRe.lastIndex;else{while((r=e[n])&&i.test(r))n++;if(n<1){s.lastIndex=0;while((r=e[n])&&!s.test(r)){s.lastIndex=0,n++;if(i.test(r)){if(n>2){n--;break}while((r=e[n])&&i.test(r))n++;if(n>2)break}}}}return s.lastIndex=0,n},this.moveCursorShortWordRight=function(){var e=this.lead.row,t=this.lead.column,n=this.doc.getLine(e),r=n.substring(t),i=this.session.getFoldAt(e,t,1);if(i)return this.moveCursorTo(i.end.row,i.end.column);if(t==n.length){var s=this.doc.getLength();do e++,r=this.doc.getLine(e);while(e<s&&/^\\s*$/.test(r));/^\\s+/.test(r)||(r=\"\"),t=0}var o=this.$shortWordEndIndex(r);this.moveCursorTo(e,t+o)},this.moveCursorShortWordLeft=function(){var e=this.lead.row,t=this.lead.column,n;if(n=this.session.getFoldAt(e,t,-1))return this.moveCursorTo(n.start.row,n.start.column);var r=this.session.getLine(e).substring(0,t);if(t===0){do e--,r=this.doc.getLine(e);while(e>0&&/^\\s*$/.test(r));t=r.length,/\\s+$/.test(r)||(r=\"\")}var s=i.stringReverse(r),o=this.$shortWordEndIndex(s);return this.moveCursorTo(e,t-o)},this.moveCursorWordRight=function(){this.session.$selectLongWords?this.moveCursorLongWordRight():this.moveCursorShortWordRight()},this.moveCursorWordLeft=function(){this.session.$selectLongWords?this.moveCursorLongWordLeft():this.moveCursorShortWordLeft()},this.moveCursorBy=function(e,t){var n=this.session.documentToScreenPosition(this.lead.row,this.lead.column);t===0&&(this.$desiredColumn?n.column=this.$desiredColumn:this.$desiredColumn=n.column);var r=this.session.screenToDocumentPosition(n.row+e,n.column);e!==0&&t===0&&r.row===this.lead.row&&r.column===this.lead.column&&this.session.lineWidgets&&this.session.lineWidgets[r.row]&&r.row++,this.moveCursorTo(r.row,r.column+t,t===0)},this.moveCursorToPosition=function(e){this.moveCursorTo(e.row,e.column)},this.moveCursorTo=function(e,t,n){var r=this.session.getFoldAt(e,t,1);r&&(e=r.start.row,t=r.start.column),this.$keepDesiredColumnOnChange=!0,this.lead.setPosition(e,t),this.$keepDesiredColumnOnChange=!1,n||(this.$desiredColumn=null)},this.moveCursorToScreen=function(e,t,n){var r=this.session.screenToDocumentPosition(e,t);this.moveCursorTo(r.row,r.column,n)},this.detach=function(){this.lead.detach(),this.anchor.detach(),this.session=this.doc=null},this.fromOrientedRange=function(e){this.setSelectionRange(e,e.cursor==e.start),this.$desiredColumn=e.desiredColumn||this.$desiredColumn},this.toOrientedRange=function(e){var t=this.getRange();return e?(e.start.column=t.start.column,e.start.row=t.start.row,e.end.column=t.end.column,e.end.row=t.end.row):e=t,e.cursor=this.isBackwards()?e.start:e.end,e.desiredColumn=this.$desiredColumn,e},this.getRangeOfMovements=function(e){var t=this.getCursor();try{e.call(null,this);var n=this.getCursor();return o.fromPoints(t,n)}catch(r){return o.fromPoints(t,t)}finally{this.moveCursorToPosition(t)}},this.toJSON=function(){if(this.rangeCount)var e=this.ranges.map(function(e){var t=e.clone();return t.isBackwards=e.cursor==e.start,t});else{var e=this.getRange();e.isBackwards=this.isBackwards()}return e},this.fromJSON=function(e){if(e.start==undefined){if(this.rangeList){this.toSingleRange(e[0]);for(var t=e.length;t--;){var n=o.fromPoints(e[t].start,e[t].end);e[t].isBackwards&&(n.cursor=n.start),this.addRange(n,!0)}return}e=e[0]}this.rangeList&&this.toSingleRange(e),this.setSelectionRange(e,e.isBackwards)},this.isEqual=function(e){if((e.length||this.rangeCount)&&e.length!=this.rangeCount)return!1;if(!e.length||!this.ranges)return this.getRange().isEqual(e);for(var t=this.ranges.length;t--;)if(!this.ranges[t].isEqual(e[t]))return!1;return!0}}).call(u.prototype),t.Selection=u}),define(\"ace/tokenizer\",[\"require\",\"exports\",\"module\",\"ace/config\"],function(e,t,n){\"use strict\";var r=e(\"./config\"),i=2e3,s=function(e){this.states=e,this.regExps={},this.matchMappings={};for(var t in this.states){var n=this.states[t],r=[],i=0,s=this.matchMappings[t]={defaultToken:\"text\"},o=\"g\",u=[];for(var a=0;a<n.length;a++){var f=n[a];f.defaultToken&&(s.defaultToken=f.defaultToken),f.caseInsensitive&&(o=\"gi\");if(f.regex==null)continue;f.regex instanceof RegExp&&(f.regex=f.regex.toString().slice(1,-1));var l=f.regex,c=(new RegExp(\"(?:(\"+l+\")|(.))\")).exec(\"a\").length-2;Array.isArray(f.token)?f.token.length==1||c==1?f.token=f.token[0]:c-1!=f.token.length?(this.reportError(\"number of classes and regexp groups doesn't match\",{rule:f,groupCount:c-1}),f.token=f.token[0]):(f.tokenArray=f.token,f.token=null,f.onMatch=this.$arrayTokens):typeof f.token==\"function\"&&!f.onMatch&&(c>1?f.onMatch=this.$applyToken:f.onMatch=f.token),c>1&&(/\\\\\\d/.test(f.regex)?l=f.regex.replace(/\\\\([0-9]+)/g,function(e,t){return\"\\\\\"+(parseInt(t,10)+i+1)}):(c=1,l=this.removeCapturingGroups(f.regex)),!f.splitRegex&&typeof f.token!=\"string\"&&u.push(f)),s[i]=a,i+=c,r.push(l),f.onMatch||(f.onMatch=null)}r.length||(s[0]=0,r.push(\"$\")),u.forEach(function(e){e.splitRegex=this.createSplitterRegexp(e.regex,o)},this),this.regExps[t]=new RegExp(\"(\"+r.join(\")|(\")+\")|($)\",o)}};(function(){this.$setMaxTokenCount=function(e){i=e|0},this.$applyToken=function(e){var t=this.splitRegex.exec(e).slice(1),n=this.token.apply(this,t);if(typeof n==\"string\")return[{type:n,value:e}];var r=[];for(var i=0,s=n.length;i<s;i++)t[i]&&(r[r.length]={type:n[i],value:t[i]});return r},this.$arrayTokens=function(e){if(!e)return[];var t=this.splitRegex.exec(e);if(!t)return\"text\";var n=[],r=this.tokenArray;for(var i=0,s=r.length;i<s;i++)t[i+1]&&(n[n.length]={type:r[i],value:t[i+1]});return n},this.removeCapturingGroups=function(e){var t=e.replace(/\\[(?:\\\\.|[^\\]])*?\\]|\\\\.|\\(\\?[:=!]|(\\()/g,function(e,t){return t?\"(?:\":e});return t},this.createSplitterRegexp=function(e,t){if(e.indexOf(\"(?=\")!=-1){var n=0,r=!1,i={};e.replace(/(\\\\.)|(\\((?:\\?[=!])?)|(\\))|([\\[\\]])/g,function(e,t,s,o,u,a){return r?r=u!=\"]\":u?r=!0:o?(n==i.stack&&(i.end=a+1,i.stack=-1),n--):s&&(n++,s.length!=1&&(i.stack=n,i.start=a)),e}),i.end!=null&&/^\\)*$/.test(e.substr(i.end))&&(e=e.substring(0,i.start)+e.substr(i.end))}return new RegExp(e,(t||\"\").replace(\"g\",\"\"))},this.getLineTokens=function(e,t){if(t&&typeof t!=\"string\"){var n=t.slice(0);t=n[0],t===\"#tmp\"&&(n.shift(),t=n.shift())}else var n=[];var r=t||\"start\",s=this.states[r];s||(r=\"start\",s=this.states[r]);var o=this.matchMappings[r],u=this.regExps[r];u.lastIndex=0;var a,f=[],l=0,c=0,h={type:null,value:\"\"};while(a=u.exec(e)){var p=o.defaultToken,d=null,v=a[0],m=u.lastIndex;if(m-v.length>l){var g=e.substring(l,m-v.length);h.type==p?h.value+=g:(h.type&&f.push(h),h={type:p,value:g})}for(var y=0;y<a.length-2;y++){if(a[y+1]===undefined)continue;d=s[o[y]],d.onMatch?p=d.onMatch(v,r,n):p=d.token,d.next&&(typeof d.next==\"string\"?r=d.next:r=d.next(r,n),s=this.states[r],s||(this.reportError(\"state doesn't exist\",r),r=\"start\",s=this.states[r]),o=this.matchMappings[r],l=m,u=this.regExps[r],u.lastIndex=m);break}if(v)if(typeof p==\"string\")!!d&&d.merge===!1||h.type!==p?(h.type&&f.push(h),h={type:p,value:v}):h.value+=v;else if(p){h.type&&f.push(h),h={type:null,value:\"\"};for(var y=0;y<p.length;y++)f.push(p[y])}if(l==e.length)break;l=m;if(c++>i){c>2*e.length&&this.reportError(\"infinite loop with in ace tokenizer\",{startState:t,line:e});while(l<e.length)h.type&&f.push(h),h={value:e.substring(l,l+=2e3),type:\"overflow\"};r=\"start\",n=[];break}}return h.type&&f.push(h),n.length>1&&n[0]!==r&&n.unshift(\"#tmp\",r),{tokens:f,state:n.length?n:r}},this.reportError=r.reportError}).call(s.prototype),t.Tokenizer=s}),define(\"ace/mode/text_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/lang\"],function(e,t,n){\"use strict\";var r=e(\"../lib/lang\"),i=function(){this.$rules={start:[{token:\"empty_line\",regex:\"^$\"},{defaultToken:\"text\"}]}};(function(){this.addRules=function(e,t){if(!t){for(var n in e)this.$rules[n]=e[n];return}for(var n in e){var r=e[n];for(var i=0;i<r.length;i++){var s=r[i];if(s.next||s.onMatch)typeof s.next!=\"string\"?s.nextState&&s.nextState.indexOf(t)!==0&&(s.nextState=t+s.nextState):s.next.indexOf(t)!==0&&(s.next=t+s.next)}this.$rules[t+n]=r}},this.getRules=function(){return this.$rules},this.embedRules=function(e,t,n,i,s){var o=typeof e==\"function\"?(new e).getRules():e;if(i)for(var u=0;u<i.length;u++)i[u]=t+i[u];else{i=[];for(var a in o)i.push(t+a)}this.addRules(o,t);if(n){var f=Array.prototype[s?\"push\":\"unshift\"];for(var u=0;u<i.length;u++)f.apply(this.$rules[i[u]],r.deepCopy(n))}this.$embeds||(this.$embeds=[]),this.$embeds.push(t)},this.getEmbeds=function(){return this.$embeds};var e=function(e,t){return(e!=\"start\"||t.length)&&t.unshift(this.nextState,e),this.nextState},t=function(e,t){return t.shift(),t.shift()||\"start\"};this.normalizeRules=function(){function i(s){var o=r[s];o.processed=!0;for(var u=0;u<o.length;u++){var a=o[u];!a.regex&&a.start&&(a.regex=a.start,a.next||(a.next=[]),a.next.push({defaultToken:a.token},{token:a.token+\".end\",regex:a.end||a.start,next:\"pop\"}),a.token=a.token+\".start\",a.push=!0);var f=a.next||a.push;if(f&&Array.isArray(f)){var l=a.stateName;l||(l=a.token,typeof l!=\"string\"&&(l=l[0]||\"\"),r[l]&&(l+=n++)),r[l]=f,a.next=l,i(l)}else f==\"pop\"&&(a.next=t);a.push&&(a.nextState=a.next||a.push,a.next=e,delete a.push);if(a.rules)for(var c in a.rules)r[c]?r[c].push&&r[c].push.apply(r[c],a.rules[c]):r[c]=a.rules[c];if(a.include||typeof a==\"string\")var h=a.include||a,p=r[h];else Array.isArray(a)&&(p=a);if(p){var d=[u,1].concat(p);a.noEscape&&(d=d.filter(function(e){return!e.next})),o.splice.apply(o,d),u--,p=null}a.keywordMap&&(a.token=this.createKeywordMapper(a.keywordMap,a.defaultToken||\"text\",a.caseInsensitive),delete a.defaultToken)}}var n=0,r=this.$rules;Object.keys(r).forEach(i,this)},this.createKeywordMapper=function(e,t,n,r){var i=Object.create(null);return Object.keys(e).forEach(function(t){var s=e[t];n&&(s=s.toLowerCase());var o=s.split(r||\"|\");for(var u=o.length;u--;)i[o[u]]=t}),Object.getPrototypeOf(i)&&(i.__proto__=null),this.$keywordList=Object.keys(i),e=null,n?function(e){return i[e.toLowerCase()]||t}:function(e){return i[e]||t}},this.getKeywords=function(){return this.$keywords}}).call(i.prototype),t.TextHighlightRules=i}),define(\"ace/mode/behaviour\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r=function(){this.$behaviours={}};(function(){this.add=function(e,t,n){switch(undefined){case this.$behaviours:this.$behaviours={};case this.$behaviours[e]:this.$behaviours[e]={}}this.$behaviours[e][t]=n},this.addBehaviours=function(e){for(var t in e)for(var n in e[t])this.add(t,n,e[t][n])},this.remove=function(e){this.$behaviours&&this.$behaviours[e]&&delete this.$behaviours[e]},this.inherit=function(e,t){if(typeof e==\"function\")var n=(new e).getBehaviours(t);else var n=e.getBehaviours(t);this.addBehaviours(n)},this.getBehaviours=function(e){if(!e)return this.$behaviours;var t={};for(var n=0;n<e.length;n++)this.$behaviours[e[n]]&&(t[e[n]]=this.$behaviours[e[n]]);return t}}).call(r.prototype),t.Behaviour=r}),define(\"ace/unicode\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";function r(e){var n=/\\w{4}/g;for(var r in e)t.packages[r]=e[r].replace(n,\"\\\\u$&\")}t.packages={},r({L:\"0041-005A0061-007A00AA00B500BA00C0-00D600D8-00F600F8-02C102C6-02D102E0-02E402EC02EE0370-037403760377037A-037D03860388-038A038C038E-03A103A3-03F503F7-0481048A-05250531-055605590561-058705D0-05EA05F0-05F20621-064A066E066F0671-06D306D506E506E606EE06EF06FA-06FC06FF07100712-072F074D-07A507B107CA-07EA07F407F507FA0800-0815081A082408280904-0939093D09500958-0961097109720979-097F0985-098C098F09900993-09A809AA-09B009B209B6-09B909BD09CE09DC09DD09DF-09E109F009F10A05-0A0A0A0F0A100A13-0A280A2A-0A300A320A330A350A360A380A390A59-0A5C0A5E0A72-0A740A85-0A8D0A8F-0A910A93-0AA80AAA-0AB00AB20AB30AB5-0AB90ABD0AD00AE00AE10B05-0B0C0B0F0B100B13-0B280B2A-0B300B320B330B35-0B390B3D0B5C0B5D0B5F-0B610B710B830B85-0B8A0B8E-0B900B92-0B950B990B9A0B9C0B9E0B9F0BA30BA40BA8-0BAA0BAE-0BB90BD00C05-0C0C0C0E-0C100C12-0C280C2A-0C330C35-0C390C3D0C580C590C600C610C85-0C8C0C8E-0C900C92-0CA80CAA-0CB30CB5-0CB90CBD0CDE0CE00CE10D05-0D0C0D0E-0D100D12-0D280D2A-0D390D3D0D600D610D7A-0D7F0D85-0D960D9A-0DB10DB3-0DBB0DBD0DC0-0DC60E01-0E300E320E330E40-0E460E810E820E840E870E880E8A0E8D0E94-0E970E99-0E9F0EA1-0EA30EA50EA70EAA0EAB0EAD-0EB00EB20EB30EBD0EC0-0EC40EC60EDC0EDD0F000F40-0F470F49-0F6C0F88-0F8B1000-102A103F1050-1055105A-105D106110651066106E-10701075-1081108E10A0-10C510D0-10FA10FC1100-1248124A-124D1250-12561258125A-125D1260-1288128A-128D1290-12B012B2-12B512B8-12BE12C012C2-12C512C8-12D612D8-13101312-13151318-135A1380-138F13A0-13F41401-166C166F-167F1681-169A16A0-16EA1700-170C170E-17111720-17311740-17511760-176C176E-17701780-17B317D717DC1820-18771880-18A818AA18B0-18F51900-191C1950-196D1970-19741980-19AB19C1-19C71A00-1A161A20-1A541AA71B05-1B331B45-1B4B1B83-1BA01BAE1BAF1C00-1C231C4D-1C4F1C5A-1C7D1CE9-1CEC1CEE-1CF11D00-1DBF1E00-1F151F18-1F1D1F20-1F451F48-1F4D1F50-1F571F591F5B1F5D1F5F-1F7D1F80-1FB41FB6-1FBC1FBE1FC2-1FC41FC6-1FCC1FD0-1FD31FD6-1FDB1FE0-1FEC1FF2-1FF41FF6-1FFC2071207F2090-209421022107210A-211321152119-211D212421262128212A-212D212F-2139213C-213F2145-2149214E218321842C00-2C2E2C30-2C5E2C60-2CE42CEB-2CEE2D00-2D252D30-2D652D6F2D80-2D962DA0-2DA62DA8-2DAE2DB0-2DB62DB8-2DBE2DC0-2DC62DC8-2DCE2DD0-2DD62DD8-2DDE2E2F300530063031-3035303B303C3041-3096309D-309F30A1-30FA30FC-30FF3105-312D3131-318E31A0-31B731F0-31FF3400-4DB54E00-9FCBA000-A48CA4D0-A4FDA500-A60CA610-A61FA62AA62BA640-A65FA662-A66EA67F-A697A6A0-A6E5A717-A71FA722-A788A78BA78CA7FB-A801A803-A805A807-A80AA80C-A822A840-A873A882-A8B3A8F2-A8F7A8FBA90A-A925A930-A946A960-A97CA984-A9B2A9CFAA00-AA28AA40-AA42AA44-AA4BAA60-AA76AA7AAA80-AAAFAAB1AAB5AAB6AAB9-AABDAAC0AAC2AADB-AADDABC0-ABE2AC00-D7A3D7B0-D7C6D7CB-D7FBF900-FA2DFA30-FA6DFA70-FAD9FB00-FB06FB13-FB17FB1DFB1F-FB28FB2A-FB36FB38-FB3CFB3EFB40FB41FB43FB44FB46-FBB1FBD3-FD3DFD50-FD8FFD92-FDC7FDF0-FDFBFE70-FE74FE76-FEFCFF21-FF3AFF41-FF5AFF66-FFBEFFC2-FFC7FFCA-FFCFFFD2-FFD7FFDA-FFDC\",Ll:\"0061-007A00AA00B500BA00DF-00F600F8-00FF01010103010501070109010B010D010F01110113011501170119011B011D011F01210123012501270129012B012D012F01310133013501370138013A013C013E014001420144014601480149014B014D014F01510153015501570159015B015D015F01610163016501670169016B016D016F0171017301750177017A017C017E-0180018301850188018C018D019201950199-019B019E01A101A301A501A801AA01AB01AD01B001B401B601B901BA01BD-01BF01C601C901CC01CE01D001D201D401D601D801DA01DC01DD01DF01E101E301E501E701E901EB01ED01EF01F001F301F501F901FB01FD01FF02010203020502070209020B020D020F02110213021502170219021B021D021F02210223022502270229022B022D022F02310233-0239023C023F0240024202470249024B024D024F-02930295-02AF037103730377037B-037D039003AC-03CE03D003D103D5-03D703D903DB03DD03DF03E103E303E503E703E903EB03ED03EF-03F303F503F803FB03FC0430-045F04610463046504670469046B046D046F04710473047504770479047B047D047F0481048B048D048F04910493049504970499049B049D049F04A104A304A504A704A904AB04AD04AF04B104B304B504B704B904BB04BD04BF04C204C404C604C804CA04CC04CE04CF04D104D304D504D704D904DB04DD04DF04E104E304E504E704E904EB04ED04EF04F104F304F504F704F904FB04FD04FF05010503050505070509050B050D050F05110513051505170519051B051D051F0521052305250561-05871D00-1D2B1D62-1D771D79-1D9A1E011E031E051E071E091E0B1E0D1E0F1E111E131E151E171E191E1B1E1D1E1F1E211E231E251E271E291E2B1E2D1E2F1E311E331E351E371E391E3B1E3D1E3F1E411E431E451E471E491E4B1E4D1E4F1E511E531E551E571E591E5B1E5D1E5F1E611E631E651E671E691E6B1E6D1E6F1E711E731E751E771E791E7B1E7D1E7F1E811E831E851E871E891E8B1E8D1E8F1E911E931E95-1E9D1E9F1EA11EA31EA51EA71EA91EAB1EAD1EAF1EB11EB31EB51EB71EB91EBB1EBD1EBF1EC11EC31EC51EC71EC91ECB1ECD1ECF1ED11ED31ED51ED71ED91EDB1EDD1EDF1EE11EE31EE51EE71EE91EEB1EED1EEF1EF11EF31EF51EF71EF91EFB1EFD1EFF-1F071F10-1F151F20-1F271F30-1F371F40-1F451F50-1F571F60-1F671F70-1F7D1F80-1F871F90-1F971FA0-1FA71FB0-1FB41FB61FB71FBE1FC2-1FC41FC61FC71FD0-1FD31FD61FD71FE0-1FE71FF2-1FF41FF61FF7210A210E210F2113212F21342139213C213D2146-2149214E21842C30-2C5E2C612C652C662C682C6A2C6C2C712C732C742C76-2C7C2C812C832C852C872C892C8B2C8D2C8F2C912C932C952C972C992C9B2C9D2C9F2CA12CA32CA52CA72CA92CAB2CAD2CAF2CB12CB32CB52CB72CB92CBB2CBD2CBF2CC12CC32CC52CC72CC92CCB2CCD2CCF2CD12CD32CD52CD72CD92CDB2CDD2CDF2CE12CE32CE42CEC2CEE2D00-2D25A641A643A645A647A649A64BA64DA64FA651A653A655A657A659A65BA65DA65FA663A665A667A669A66BA66DA681A683A685A687A689A68BA68DA68FA691A693A695A697A723A725A727A729A72BA72DA72F-A731A733A735A737A739A73BA73DA73FA741A743A745A747A749A74BA74DA74FA751A753A755A757A759A75BA75DA75FA761A763A765A767A769A76BA76DA76FA771-A778A77AA77CA77FA781A783A785A787A78CFB00-FB06FB13-FB17FF41-FF5A\",Lu:\"0041-005A00C0-00D600D8-00DE01000102010401060108010A010C010E01100112011401160118011A011C011E01200122012401260128012A012C012E01300132013401360139013B013D013F0141014301450147014A014C014E01500152015401560158015A015C015E01600162016401660168016A016C016E017001720174017601780179017B017D018101820184018601870189-018B018E-0191019301940196-0198019C019D019F01A001A201A401A601A701A901AC01AE01AF01B1-01B301B501B701B801BC01C401C701CA01CD01CF01D101D301D501D701D901DB01DE01E001E201E401E601E801EA01EC01EE01F101F401F6-01F801FA01FC01FE02000202020402060208020A020C020E02100212021402160218021A021C021E02200222022402260228022A022C022E02300232023A023B023D023E02410243-02460248024A024C024E03700372037603860388-038A038C038E038F0391-03A103A3-03AB03CF03D2-03D403D803DA03DC03DE03E003E203E403E603E803EA03EC03EE03F403F703F903FA03FD-042F04600462046404660468046A046C046E04700472047404760478047A047C047E0480048A048C048E04900492049404960498049A049C049E04A004A204A404A604A804AA04AC04AE04B004B204B404B604B804BA04BC04BE04C004C104C304C504C704C904CB04CD04D004D204D404D604D804DA04DC04DE04E004E204E404E604E804EA04EC04EE04F004F204F404F604F804FA04FC04FE05000502050405060508050A050C050E05100512051405160518051A051C051E0520052205240531-055610A0-10C51E001E021E041E061E081E0A1E0C1E0E1E101E121E141E161E181E1A1E1C1E1E1E201E221E241E261E281E2A1E2C1E2E1E301E321E341E361E381E3A1E3C1E3E1E401E421E441E461E481E4A1E4C1E4E1E501E521E541E561E581E5A1E5C1E5E1E601E621E641E661E681E6A1E6C1E6E1E701E721E741E761E781E7A1E7C1E7E1E801E821E841E861E881E8A1E8C1E8E1E901E921E941E9E1EA01EA21EA41EA61EA81EAA1EAC1EAE1EB01EB21EB41EB61EB81EBA1EBC1EBE1EC01EC21EC41EC61EC81ECA1ECC1ECE1ED01ED21ED41ED61ED81EDA1EDC1EDE1EE01EE21EE41EE61EE81EEA1EEC1EEE1EF01EF21EF41EF61EF81EFA1EFC1EFE1F08-1F0F1F18-1F1D1F28-1F2F1F38-1F3F1F48-1F4D1F591F5B1F5D1F5F1F68-1F6F1FB8-1FBB1FC8-1FCB1FD8-1FDB1FE8-1FEC1FF8-1FFB21022107210B-210D2110-211221152119-211D212421262128212A-212D2130-2133213E213F214521832C00-2C2E2C602C62-2C642C672C692C6B2C6D-2C702C722C752C7E-2C802C822C842C862C882C8A2C8C2C8E2C902C922C942C962C982C9A2C9C2C9E2CA02CA22CA42CA62CA82CAA2CAC2CAE2CB02CB22CB42CB62CB82CBA2CBC2CBE2CC02CC22CC42CC62CC82CCA2CCC2CCE2CD02CD22CD42CD62CD82CDA2CDC2CDE2CE02CE22CEB2CEDA640A642A644A646A648A64AA64CA64EA650A652A654A656A658A65AA65CA65EA662A664A666A668A66AA66CA680A682A684A686A688A68AA68CA68EA690A692A694A696A722A724A726A728A72AA72CA72EA732A734A736A738A73AA73CA73EA740A742A744A746A748A74AA74CA74EA750A752A754A756A758A75AA75CA75EA760A762A764A766A768A76AA76CA76EA779A77BA77DA77EA780A782A784A786A78BFF21-FF3A\",Lt:\"01C501C801CB01F21F88-1F8F1F98-1F9F1FA8-1FAF1FBC1FCC1FFC\",Lm:\"02B0-02C102C6-02D102E0-02E402EC02EE0374037A0559064006E506E607F407F507FA081A0824082809710E460EC610FC17D718431AA71C78-1C7D1D2C-1D611D781D9B-1DBF2071207F2090-20942C7D2D6F2E2F30053031-3035303B309D309E30FC-30FEA015A4F8-A4FDA60CA67FA717-A71FA770A788A9CFAA70AADDFF70FF9EFF9F\",Lo:\"01BB01C0-01C3029405D0-05EA05F0-05F20621-063F0641-064A066E066F0671-06D306D506EE06EF06FA-06FC06FF07100712-072F074D-07A507B107CA-07EA0800-08150904-0939093D09500958-096109720979-097F0985-098C098F09900993-09A809AA-09B009B209B6-09B909BD09CE09DC09DD09DF-09E109F009F10A05-0A0A0A0F0A100A13-0A280A2A-0A300A320A330A350A360A380A390A59-0A5C0A5E0A72-0A740A85-0A8D0A8F-0A910A93-0AA80AAA-0AB00AB20AB30AB5-0AB90ABD0AD00AE00AE10B05-0B0C0B0F0B100B13-0B280B2A-0B300B320B330B35-0B390B3D0B5C0B5D0B5F-0B610B710B830B85-0B8A0B8E-0B900B92-0B950B990B9A0B9C0B9E0B9F0BA30BA40BA8-0BAA0BAE-0BB90BD00C05-0C0C0C0E-0C100C12-0C280C2A-0C330C35-0C390C3D0C580C590C600C610C85-0C8C0C8E-0C900C92-0CA80CAA-0CB30CB5-0CB90CBD0CDE0CE00CE10D05-0D0C0D0E-0D100D12-0D280D2A-0D390D3D0D600D610D7A-0D7F0D85-0D960D9A-0DB10DB3-0DBB0DBD0DC0-0DC60E01-0E300E320E330E40-0E450E810E820E840E870E880E8A0E8D0E94-0E970E99-0E9F0EA1-0EA30EA50EA70EAA0EAB0EAD-0EB00EB20EB30EBD0EC0-0EC40EDC0EDD0F000F40-0F470F49-0F6C0F88-0F8B1000-102A103F1050-1055105A-105D106110651066106E-10701075-1081108E10D0-10FA1100-1248124A-124D1250-12561258125A-125D1260-1288128A-128D1290-12B012B2-12B512B8-12BE12C012C2-12C512C8-12D612D8-13101312-13151318-135A1380-138F13A0-13F41401-166C166F-167F1681-169A16A0-16EA1700-170C170E-17111720-17311740-17511760-176C176E-17701780-17B317DC1820-18421844-18771880-18A818AA18B0-18F51900-191C1950-196D1970-19741980-19AB19C1-19C71A00-1A161A20-1A541B05-1B331B45-1B4B1B83-1BA01BAE1BAF1C00-1C231C4D-1C4F1C5A-1C771CE9-1CEC1CEE-1CF12135-21382D30-2D652D80-2D962DA0-2DA62DA8-2DAE2DB0-2DB62DB8-2DBE2DC0-2DC62DC8-2DCE2DD0-2DD62DD8-2DDE3006303C3041-3096309F30A1-30FA30FF3105-312D3131-318E31A0-31B731F0-31FF3400-4DB54E00-9FCBA000-A014A016-A48CA4D0-A4F7A500-A60BA610-A61FA62AA62BA66EA6A0-A6E5A7FB-A801A803-A805A807-A80AA80C-A822A840-A873A882-A8B3A8F2-A8F7A8FBA90A-A925A930-A946A960-A97CA984-A9B2AA00-AA28AA40-AA42AA44-AA4BAA60-AA6FAA71-AA76AA7AAA80-AAAFAAB1AAB5AAB6AAB9-AABDAAC0AAC2AADBAADCABC0-ABE2AC00-D7A3D7B0-D7C6D7CB-D7FBF900-FA2DFA30-FA6DFA70-FAD9FB1DFB1F-FB28FB2A-FB36FB38-FB3CFB3EFB40FB41FB43FB44FB46-FBB1FBD3-FD3DFD50-FD8FFD92-FDC7FDF0-FDFBFE70-FE74FE76-FEFCFF66-FF6FFF71-FF9DFFA0-FFBEFFC2-FFC7FFCA-FFCFFFD2-FFD7FFDA-FFDC\",M:\"0300-036F0483-04890591-05BD05BF05C105C205C405C505C70610-061A064B-065E067006D6-06DC06DE-06E406E706E806EA-06ED07110730-074A07A6-07B007EB-07F30816-0819081B-08230825-08270829-082D0900-0903093C093E-094E0951-0955096209630981-098309BC09BE-09C409C709C809CB-09CD09D709E209E30A01-0A030A3C0A3E-0A420A470A480A4B-0A4D0A510A700A710A750A81-0A830ABC0ABE-0AC50AC7-0AC90ACB-0ACD0AE20AE30B01-0B030B3C0B3E-0B440B470B480B4B-0B4D0B560B570B620B630B820BBE-0BC20BC6-0BC80BCA-0BCD0BD70C01-0C030C3E-0C440C46-0C480C4A-0C4D0C550C560C620C630C820C830CBC0CBE-0CC40CC6-0CC80CCA-0CCD0CD50CD60CE20CE30D020D030D3E-0D440D46-0D480D4A-0D4D0D570D620D630D820D830DCA0DCF-0DD40DD60DD8-0DDF0DF20DF30E310E34-0E3A0E47-0E4E0EB10EB4-0EB90EBB0EBC0EC8-0ECD0F180F190F350F370F390F3E0F3F0F71-0F840F860F870F90-0F970F99-0FBC0FC6102B-103E1056-1059105E-10601062-10641067-106D1071-10741082-108D108F109A-109D135F1712-17141732-1734175217531772177317B6-17D317DD180B-180D18A91920-192B1930-193B19B0-19C019C819C91A17-1A1B1A55-1A5E1A60-1A7C1A7F1B00-1B041B34-1B441B6B-1B731B80-1B821BA1-1BAA1C24-1C371CD0-1CD21CD4-1CE81CED1CF21DC0-1DE61DFD-1DFF20D0-20F02CEF-2CF12DE0-2DFF302A-302F3099309AA66F-A672A67CA67DA6F0A6F1A802A806A80BA823-A827A880A881A8B4-A8C4A8E0-A8F1A926-A92DA947-A953A980-A983A9B3-A9C0AA29-AA36AA43AA4CAA4DAA7BAAB0AAB2-AAB4AAB7AAB8AABEAABFAAC1ABE3-ABEAABECABEDFB1EFE00-FE0FFE20-FE26\",Mn:\"0300-036F0483-04870591-05BD05BF05C105C205C405C505C70610-061A064B-065E067006D6-06DC06DF-06E406E706E806EA-06ED07110730-074A07A6-07B007EB-07F30816-0819081B-08230825-08270829-082D0900-0902093C0941-0948094D0951-095509620963098109BC09C1-09C409CD09E209E30A010A020A3C0A410A420A470A480A4B-0A4D0A510A700A710A750A810A820ABC0AC1-0AC50AC70AC80ACD0AE20AE30B010B3C0B3F0B41-0B440B4D0B560B620B630B820BC00BCD0C3E-0C400C46-0C480C4A-0C4D0C550C560C620C630CBC0CBF0CC60CCC0CCD0CE20CE30D41-0D440D4D0D620D630DCA0DD2-0DD40DD60E310E34-0E3A0E47-0E4E0EB10EB4-0EB90EBB0EBC0EC8-0ECD0F180F190F350F370F390F71-0F7E0F80-0F840F860F870F90-0F970F99-0FBC0FC6102D-10301032-10371039103A103D103E10581059105E-10601071-1074108210851086108D109D135F1712-17141732-1734175217531772177317B7-17BD17C617C9-17D317DD180B-180D18A91920-19221927192819321939-193B1A171A181A561A58-1A5E1A601A621A65-1A6C1A73-1A7C1A7F1B00-1B031B341B36-1B3A1B3C1B421B6B-1B731B801B811BA2-1BA51BA81BA91C2C-1C331C361C371CD0-1CD21CD4-1CE01CE2-1CE81CED1DC0-1DE61DFD-1DFF20D0-20DC20E120E5-20F02CEF-2CF12DE0-2DFF302A-302F3099309AA66FA67CA67DA6F0A6F1A802A806A80BA825A826A8C4A8E0-A8F1A926-A92DA947-A951A980-A982A9B3A9B6-A9B9A9BCAA29-AA2EAA31AA32AA35AA36AA43AA4CAAB0AAB2-AAB4AAB7AAB8AABEAABFAAC1ABE5ABE8ABEDFB1EFE00-FE0FFE20-FE26\",Mc:\"0903093E-09400949-094C094E0982098309BE-09C009C709C809CB09CC09D70A030A3E-0A400A830ABE-0AC00AC90ACB0ACC0B020B030B3E0B400B470B480B4B0B4C0B570BBE0BBF0BC10BC20BC6-0BC80BCA-0BCC0BD70C01-0C030C41-0C440C820C830CBE0CC0-0CC40CC70CC80CCA0CCB0CD50CD60D020D030D3E-0D400D46-0D480D4A-0D4C0D570D820D830DCF-0DD10DD8-0DDF0DF20DF30F3E0F3F0F7F102B102C10311038103B103C105610571062-10641067-106D108310841087-108C108F109A-109C17B617BE-17C517C717C81923-19261929-192B193019311933-193819B0-19C019C819C91A19-1A1B1A551A571A611A631A641A6D-1A721B041B351B3B1B3D-1B411B431B441B821BA11BA61BA71BAA1C24-1C2B1C341C351CE11CF2A823A824A827A880A881A8B4-A8C3A952A953A983A9B4A9B5A9BAA9BBA9BD-A9C0AA2FAA30AA33AA34AA4DAA7BABE3ABE4ABE6ABE7ABE9ABEAABEC\",Me:\"0488048906DE20DD-20E020E2-20E4A670-A672\",N:\"0030-003900B200B300B900BC-00BE0660-066906F0-06F907C0-07C90966-096F09E6-09EF09F4-09F90A66-0A6F0AE6-0AEF0B66-0B6F0BE6-0BF20C66-0C6F0C78-0C7E0CE6-0CEF0D66-0D750E50-0E590ED0-0ED90F20-0F331040-10491090-10991369-137C16EE-16F017E0-17E917F0-17F91810-18191946-194F19D0-19DA1A80-1A891A90-1A991B50-1B591BB0-1BB91C40-1C491C50-1C5920702074-20792080-20892150-21822185-21892460-249B24EA-24FF2776-27932CFD30073021-30293038-303A3192-31953220-32293251-325F3280-328932B1-32BFA620-A629A6E6-A6EFA830-A835A8D0-A8D9A900-A909A9D0-A9D9AA50-AA59ABF0-ABF9FF10-FF19\",Nd:\"0030-00390660-066906F0-06F907C0-07C90966-096F09E6-09EF0A66-0A6F0AE6-0AEF0B66-0B6F0BE6-0BEF0C66-0C6F0CE6-0CEF0D66-0D6F0E50-0E590ED0-0ED90F20-0F291040-10491090-109917E0-17E91810-18191946-194F19D0-19DA1A80-1A891A90-1A991B50-1B591BB0-1BB91C40-1C491C50-1C59A620-A629A8D0-A8D9A900-A909A9D0-A9D9AA50-AA59ABF0-ABF9FF10-FF19\",Nl:\"16EE-16F02160-21822185-218830073021-30293038-303AA6E6-A6EF\",No:\"00B200B300B900BC-00BE09F4-09F90BF0-0BF20C78-0C7E0D70-0D750F2A-0F331369-137C17F0-17F920702074-20792080-20892150-215F21892460-249B24EA-24FF2776-27932CFD3192-31953220-32293251-325F3280-328932B1-32BFA830-A835\",P:\"0021-00230025-002A002C-002F003A003B003F0040005B-005D005F007B007D00A100AB00B700BB00BF037E0387055A-055F0589058A05BE05C005C305C605F305F40609060A060C060D061B061E061F066A-066D06D40700-070D07F7-07F90830-083E0964096509700DF40E4F0E5A0E5B0F04-0F120F3A-0F3D0F850FD0-0FD4104A-104F10FB1361-13681400166D166E169B169C16EB-16ED1735173617D4-17D617D8-17DA1800-180A1944194519DE19DF1A1E1A1F1AA0-1AA61AA8-1AAD1B5A-1B601C3B-1C3F1C7E1C7F1CD32010-20272030-20432045-20512053-205E207D207E208D208E2329232A2768-277527C527C627E6-27EF2983-299829D8-29DB29FC29FD2CF9-2CFC2CFE2CFF2E00-2E2E2E302E313001-30033008-30113014-301F3030303D30A030FBA4FEA4FFA60D-A60FA673A67EA6F2-A6F7A874-A877A8CEA8CFA8F8-A8FAA92EA92FA95FA9C1-A9CDA9DEA9DFAA5C-AA5FAADEAADFABEBFD3EFD3FFE10-FE19FE30-FE52FE54-FE61FE63FE68FE6AFE6BFF01-FF03FF05-FF0AFF0C-FF0FFF1AFF1BFF1FFF20FF3B-FF3DFF3FFF5BFF5DFF5F-FF65\",Pd:\"002D058A05BE140018062010-20152E172E1A301C303030A0FE31FE32FE58FE63FF0D\",Ps:\"0028005B007B0F3A0F3C169B201A201E2045207D208D23292768276A276C276E27702772277427C527E627E827EA27EC27EE2983298529872989298B298D298F299129932995299729D829DA29FC2E222E242E262E283008300A300C300E3010301430163018301A301DFD3EFE17FE35FE37FE39FE3BFE3DFE3FFE41FE43FE47FE59FE5BFE5DFF08FF3BFF5BFF5FFF62\",Pe:\"0029005D007D0F3B0F3D169C2046207E208E232A2769276B276D276F27712773277527C627E727E927EB27ED27EF298429862988298A298C298E2990299229942996299829D929DB29FD2E232E252E272E293009300B300D300F3011301530173019301B301E301FFD3FFE18FE36FE38FE3AFE3CFE3EFE40FE42FE44FE48FE5AFE5CFE5EFF09FF3DFF5DFF60FF63\",Pi:\"00AB2018201B201C201F20392E022E042E092E0C2E1C2E20\",Pf:\"00BB2019201D203A2E032E052E0A2E0D2E1D2E21\",Pc:\"005F203F20402054FE33FE34FE4D-FE4FFF3F\",Po:\"0021-00230025-0027002A002C002E002F003A003B003F0040005C00A100B700BF037E0387055A-055F058905C005C305C605F305F40609060A060C060D061B061E061F066A-066D06D40700-070D07F7-07F90830-083E0964096509700DF40E4F0E5A0E5B0F04-0F120F850FD0-0FD4104A-104F10FB1361-1368166D166E16EB-16ED1735173617D4-17D617D8-17DA1800-18051807-180A1944194519DE19DF1A1E1A1F1AA0-1AA61AA8-1AAD1B5A-1B601C3B-1C3F1C7E1C7F1CD3201620172020-20272030-2038203B-203E2041-20432047-205120532055-205E2CF9-2CFC2CFE2CFF2E002E012E06-2E082E0B2E0E-2E162E182E192E1B2E1E2E1F2E2A-2E2E2E302E313001-3003303D30FBA4FEA4FFA60D-A60FA673A67EA6F2-A6F7A874-A877A8CEA8CFA8F8-A8FAA92EA92FA95FA9C1-A9CDA9DEA9DFAA5C-AA5FAADEAADFABEBFE10-FE16FE19FE30FE45FE46FE49-FE4CFE50-FE52FE54-FE57FE5F-FE61FE68FE6AFE6BFF01-FF03FF05-FF07FF0AFF0CFF0EFF0FFF1AFF1BFF1FFF20FF3CFF61FF64FF65\",S:\"0024002B003C-003E005E0060007C007E00A2-00A900AC00AE-00B100B400B600B800D700F702C2-02C502D2-02DF02E5-02EB02ED02EF-02FF03750384038503F604820606-0608060B060E060F06E906FD06FE07F609F209F309FA09FB0AF10B700BF3-0BFA0C7F0CF10CF20D790E3F0F01-0F030F13-0F170F1A-0F1F0F340F360F380FBE-0FC50FC7-0FCC0FCE0FCF0FD5-0FD8109E109F13601390-139917DB194019E0-19FF1B61-1B6A1B74-1B7C1FBD1FBF-1FC11FCD-1FCF1FDD-1FDF1FED-1FEF1FFD1FFE20442052207A-207C208A-208C20A0-20B8210021012103-21062108210921142116-2118211E-2123212521272129212E213A213B2140-2144214A-214D214F2190-2328232B-23E82400-24262440-244A249C-24E92500-26CD26CF-26E126E326E8-26FF2701-27042706-2709270C-27272729-274B274D274F-27522756-275E2761-276727942798-27AF27B1-27BE27C0-27C427C7-27CA27CC27D0-27E527F0-29822999-29D729DC-29FB29FE-2B4C2B50-2B592CE5-2CEA2E80-2E992E9B-2EF32F00-2FD52FF0-2FFB300430123013302030363037303E303F309B309C319031913196-319F31C0-31E33200-321E322A-32503260-327F328A-32B032C0-32FE3300-33FF4DC0-4DFFA490-A4C6A700-A716A720A721A789A78AA828-A82BA836-A839AA77-AA79FB29FDFCFDFDFE62FE64-FE66FE69FF04FF0BFF1C-FF1EFF3EFF40FF5CFF5EFFE0-FFE6FFE8-FFEEFFFCFFFD\",Sm:\"002B003C-003E007C007E00AC00B100D700F703F60606-060820442052207A-207C208A-208C2140-2144214B2190-2194219A219B21A021A321A621AE21CE21CF21D221D421F4-22FF2308-230B23202321237C239B-23B323DC-23E125B725C125F8-25FF266F27C0-27C427C7-27CA27CC27D0-27E527F0-27FF2900-29822999-29D729DC-29FB29FE-2AFF2B30-2B442B47-2B4CFB29FE62FE64-FE66FF0BFF1C-FF1EFF5CFF5EFFE2FFE9-FFEC\",Sc:\"002400A2-00A5060B09F209F309FB0AF10BF90E3F17DB20A0-20B8A838FDFCFE69FF04FFE0FFE1FFE5FFE6\",Sk:\"005E006000A800AF00B400B802C2-02C502D2-02DF02E5-02EB02ED02EF-02FF0375038403851FBD1FBF-1FC11FCD-1FCF1FDD-1FDF1FED-1FEF1FFD1FFE309B309CA700-A716A720A721A789A78AFF3EFF40FFE3\",So:\"00A600A700A900AE00B000B60482060E060F06E906FD06FE07F609FA0B700BF3-0BF80BFA0C7F0CF10CF20D790F01-0F030F13-0F170F1A-0F1F0F340F360F380FBE-0FC50FC7-0FCC0FCE0FCF0FD5-0FD8109E109F13601390-1399194019E0-19FF1B61-1B6A1B74-1B7C210021012103-21062108210921142116-2118211E-2123212521272129212E213A213B214A214C214D214F2195-2199219C-219F21A121A221A421A521A7-21AD21AF-21CD21D021D121D321D5-21F32300-2307230C-231F2322-2328232B-237B237D-239A23B4-23DB23E2-23E82400-24262440-244A249C-24E92500-25B625B8-25C025C2-25F72600-266E2670-26CD26CF-26E126E326E8-26FF2701-27042706-2709270C-27272729-274B274D274F-27522756-275E2761-276727942798-27AF27B1-27BE2800-28FF2B00-2B2F2B452B462B50-2B592CE5-2CEA2E80-2E992E9B-2EF32F00-2FD52FF0-2FFB300430123013302030363037303E303F319031913196-319F31C0-31E33200-321E322A-32503260-327F328A-32B032C0-32FE3300-33FF4DC0-4DFFA490-A4C6A828-A82BA836A837A839AA77-AA79FDFDFFE4FFE8FFEDFFEEFFFCFFFD\",Z:\"002000A01680180E2000-200A20282029202F205F3000\",Zs:\"002000A01680180E2000-200A202F205F3000\",Zl:\"2028\",Zp:\"2029\",C:\"0000-001F007F-009F00AD03780379037F-0383038B038D03A20526-05300557055805600588058B-059005C8-05CF05EB-05EF05F5-0605061C061D0620065F06DD070E070F074B074C07B2-07BF07FB-07FF082E082F083F-08FF093A093B094F095609570973-097809800984098D098E0991099209A909B109B3-09B509BA09BB09C509C609C909CA09CF-09D609D8-09DB09DE09E409E509FC-0A000A040A0B-0A0E0A110A120A290A310A340A370A3A0A3B0A3D0A43-0A460A490A4A0A4E-0A500A52-0A580A5D0A5F-0A650A76-0A800A840A8E0A920AA90AB10AB40ABA0ABB0AC60ACA0ACE0ACF0AD1-0ADF0AE40AE50AF00AF2-0B000B040B0D0B0E0B110B120B290B310B340B3A0B3B0B450B460B490B4A0B4E-0B550B58-0B5B0B5E0B640B650B72-0B810B840B8B-0B8D0B910B96-0B980B9B0B9D0BA0-0BA20BA5-0BA70BAB-0BAD0BBA-0BBD0BC3-0BC50BC90BCE0BCF0BD1-0BD60BD8-0BE50BFB-0C000C040C0D0C110C290C340C3A-0C3C0C450C490C4E-0C540C570C5A-0C5F0C640C650C70-0C770C800C810C840C8D0C910CA90CB40CBA0CBB0CC50CC90CCE-0CD40CD7-0CDD0CDF0CE40CE50CF00CF3-0D010D040D0D0D110D290D3A-0D3C0D450D490D4E-0D560D58-0D5F0D640D650D76-0D780D800D810D840D97-0D990DB20DBC0DBE0DBF0DC7-0DC90DCB-0DCE0DD50DD70DE0-0DF10DF5-0E000E3B-0E3E0E5C-0E800E830E850E860E890E8B0E8C0E8E-0E930E980EA00EA40EA60EA80EA90EAC0EBA0EBE0EBF0EC50EC70ECE0ECF0EDA0EDB0EDE-0EFF0F480F6D-0F700F8C-0F8F0F980FBD0FCD0FD9-0FFF10C6-10CF10FD-10FF1249124E124F12571259125E125F1289128E128F12B112B612B712BF12C112C612C712D7131113161317135B-135E137D-137F139A-139F13F5-13FF169D-169F16F1-16FF170D1715-171F1737-173F1754-175F176D17711774-177F17B417B517DE17DF17EA-17EF17FA-17FF180F181A-181F1878-187F18AB-18AF18F6-18FF191D-191F192C-192F193C-193F1941-1943196E196F1975-197F19AC-19AF19CA-19CF19DB-19DD1A1C1A1D1A5F1A7D1A7E1A8A-1A8F1A9A-1A9F1AAE-1AFF1B4C-1B4F1B7D-1B7F1BAB-1BAD1BBA-1BFF1C38-1C3A1C4A-1C4C1C80-1CCF1CF3-1CFF1DE7-1DFC1F161F171F1E1F1F1F461F471F4E1F4F1F581F5A1F5C1F5E1F7E1F7F1FB51FC51FD41FD51FDC1FF01FF11FF51FFF200B-200F202A-202E2060-206F20722073208F2095-209F20B9-20CF20F1-20FF218A-218F23E9-23FF2427-243F244B-245F26CE26E226E4-26E727002705270A270B2728274C274E2753-2755275F27602795-279727B027BF27CB27CD-27CF2B4D-2B4F2B5A-2BFF2C2F2C5F2CF2-2CF82D26-2D2F2D66-2D6E2D70-2D7F2D97-2D9F2DA72DAF2DB72DBF2DC72DCF2DD72DDF2E32-2E7F2E9A2EF4-2EFF2FD6-2FEF2FFC-2FFF3040309730983100-3104312E-3130318F31B8-31BF31E4-31EF321F32FF4DB6-4DBF9FCC-9FFFA48D-A48FA4C7-A4CFA62C-A63FA660A661A674-A67BA698-A69FA6F8-A6FFA78D-A7FAA82C-A82FA83A-A83FA878-A87FA8C5-A8CDA8DA-A8DFA8FC-A8FFA954-A95EA97D-A97FA9CEA9DA-A9DDA9E0-A9FFAA37-AA3FAA4EAA4FAA5AAA5BAA7C-AA7FAAC3-AADAAAE0-ABBFABEEABEFABFA-ABFFD7A4-D7AFD7C7-D7CAD7FC-F8FFFA2EFA2FFA6EFA6FFADA-FAFFFB07-FB12FB18-FB1CFB37FB3DFB3FFB42FB45FBB2-FBD2FD40-FD4FFD90FD91FDC8-FDEFFDFEFDFFFE1A-FE1FFE27-FE2FFE53FE67FE6C-FE6FFE75FEFD-FF00FFBF-FFC1FFC8FFC9FFD0FFD1FFD8FFD9FFDD-FFDFFFE7FFEF-FFFBFFFEFFFF\",Cc:\"0000-001F007F-009F\",Cf:\"00AD0600-060306DD070F17B417B5200B-200F202A-202E2060-2064206A-206FFEFFFFF9-FFFB\",Co:\"E000-F8FF\",Cs:\"D800-DFFF\",Cn:\"03780379037F-0383038B038D03A20526-05300557055805600588058B-059005C8-05CF05EB-05EF05F5-05FF06040605061C061D0620065F070E074B074C07B2-07BF07FB-07FF082E082F083F-08FF093A093B094F095609570973-097809800984098D098E0991099209A909B109B3-09B509BA09BB09C509C609C909CA09CF-09D609D8-09DB09DE09E409E509FC-0A000A040A0B-0A0E0A110A120A290A310A340A370A3A0A3B0A3D0A43-0A460A490A4A0A4E-0A500A52-0A580A5D0A5F-0A650A76-0A800A840A8E0A920AA90AB10AB40ABA0ABB0AC60ACA0ACE0ACF0AD1-0ADF0AE40AE50AF00AF2-0B000B040B0D0B0E0B110B120B290B310B340B3A0B3B0B450B460B490B4A0B4E-0B550B58-0B5B0B5E0B640B650B72-0B810B840B8B-0B8D0B910B96-0B980B9B0B9D0BA0-0BA20BA5-0BA70BAB-0BAD0BBA-0BBD0BC3-0BC50BC90BCE0BCF0BD1-0BD60BD8-0BE50BFB-0C000C040C0D0C110C290C340C3A-0C3C0C450C490C4E-0C540C570C5A-0C5F0C640C650C70-0C770C800C810C840C8D0C910CA90CB40CBA0CBB0CC50CC90CCE-0CD40CD7-0CDD0CDF0CE40CE50CF00CF3-0D010D040D0D0D110D290D3A-0D3C0D450D490D4E-0D560D58-0D5F0D640D650D76-0D780D800D810D840D97-0D990DB20DBC0DBE0DBF0DC7-0DC90DCB-0DCE0DD50DD70DE0-0DF10DF5-0E000E3B-0E3E0E5C-0E800E830E850E860E890E8B0E8C0E8E-0E930E980EA00EA40EA60EA80EA90EAC0EBA0EBE0EBF0EC50EC70ECE0ECF0EDA0EDB0EDE-0EFF0F480F6D-0F700F8C-0F8F0F980FBD0FCD0FD9-0FFF10C6-10CF10FD-10FF1249124E124F12571259125E125F1289128E128F12B112B612B712BF12C112C612C712D7131113161317135B-135E137D-137F139A-139F13F5-13FF169D-169F16F1-16FF170D1715-171F1737-173F1754-175F176D17711774-177F17DE17DF17EA-17EF17FA-17FF180F181A-181F1878-187F18AB-18AF18F6-18FF191D-191F192C-192F193C-193F1941-1943196E196F1975-197F19AC-19AF19CA-19CF19DB-19DD1A1C1A1D1A5F1A7D1A7E1A8A-1A8F1A9A-1A9F1AAE-1AFF1B4C-1B4F1B7D-1B7F1BAB-1BAD1BBA-1BFF1C38-1C3A1C4A-1C4C1C80-1CCF1CF3-1CFF1DE7-1DFC1F161F171F1E1F1F1F461F471F4E1F4F1F581F5A1F5C1F5E1F7E1F7F1FB51FC51FD41FD51FDC1FF01FF11FF51FFF2065-206920722073208F2095-209F20B9-20CF20F1-20FF218A-218F23E9-23FF2427-243F244B-245F26CE26E226E4-26E727002705270A270B2728274C274E2753-2755275F27602795-279727B027BF27CB27CD-27CF2B4D-2B4F2B5A-2BFF2C2F2C5F2CF2-2CF82D26-2D2F2D66-2D6E2D70-2D7F2D97-2D9F2DA72DAF2DB72DBF2DC72DCF2DD72DDF2E32-2E7F2E9A2EF4-2EFF2FD6-2FEF2FFC-2FFF3040309730983100-3104312E-3130318F31B8-31BF31E4-31EF321F32FF4DB6-4DBF9FCC-9FFFA48D-A48FA4C7-A4CFA62C-A63FA660A661A674-A67BA698-A69FA6F8-A6FFA78D-A7FAA82C-A82FA83A-A83FA878-A87FA8C5-A8CDA8DA-A8DFA8FC-A8FFA954-A95EA97D-A97FA9CEA9DA-A9DDA9E0-A9FFAA37-AA3FAA4EAA4FAA5AAA5BAA7C-AA7FAAC3-AADAAAE0-ABBFABEEABEFABFA-ABFFD7A4-D7AFD7C7-D7CAD7FC-D7FFFA2EFA2FFA6EFA6FFADA-FAFFFB07-FB12FB18-FB1CFB37FB3DFB3FFB42FB45FBB2-FBD2FD40-FD4FFD90FD91FDC8-FDEFFDFEFDFFFE1A-FE1FFE27-FE2FFE53FE67FE6C-FE6FFE75FEFDFEFEFF00FFBF-FFC1FFC8FFC9FFD0FFD1FFD8FFD9FFDD-FFDFFFE7FFEF-FFF8FFFEFFFF\"})}),define(\"ace/token_iterator\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r=function(e,t,n){this.$session=e,this.$row=t,this.$rowTokens=e.getTokens(t);var r=e.getTokenAt(t,n);this.$tokenIndex=r?r.index:-1};(function(){this.stepBackward=function(){this.$tokenIndex-=1;while(this.$tokenIndex<0){this.$row-=1;if(this.$row<0)return this.$row=0,null;this.$rowTokens=this.$session.getTokens(this.$row),this.$tokenIndex=this.$rowTokens.length-1}return this.$rowTokens[this.$tokenIndex]},this.stepForward=function(){this.$tokenIndex+=1;var e;while(this.$tokenIndex>=this.$rowTokens.length){this.$row+=1,e||(e=this.$session.getLength());if(this.$row>=e)return this.$row=e-1,null;this.$rowTokens=this.$session.getTokens(this.$row),this.$tokenIndex=0}return this.$rowTokens[this.$tokenIndex]},this.getCurrentToken=function(){return this.$rowTokens[this.$tokenIndex]},this.getCurrentTokenRow=function(){return this.$row},this.getCurrentTokenColumn=function(){var e=this.$rowTokens,t=this.$tokenIndex,n=e[t].start;if(n!==undefined)return n;n=0;while(t>0)t-=1,n+=e[t].value.length;return n},this.getCurrentTokenPosition=function(){return{row:this.$row,column:this.getCurrentTokenColumn()}}}).call(r.prototype),t.TokenIterator=r}),define(\"ace/mode/text\",[\"require\",\"exports\",\"module\",\"ace/tokenizer\",\"ace/mode/text_highlight_rules\",\"ace/mode/behaviour\",\"ace/unicode\",\"ace/lib/lang\",\"ace/token_iterator\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"../tokenizer\").Tokenizer,i=e(\"./text_highlight_rules\").TextHighlightRules,s=e(\"./behaviour\").Behaviour,o=e(\"../unicode\"),u=e(\"../lib/lang\"),a=e(\"../token_iterator\").TokenIterator,f=e(\"../range\").Range,l=function(){this.HighlightRules=i,this.$behaviour=new s};(function(){this.tokenRe=new RegExp(\"^[\"+o.packages.L+o.packages.Mn+o.packages.Mc+o.packages.Nd+o.packages.Pc+\"\\\\$_]+\",\"g\"),this.nonTokenRe=new RegExp(\"^(?:[^\"+o.packages.L+o.packages.Mn+o.packages.Mc+o.packages.Nd+o.packages.Pc+\"\\\\$_]|\\\\s])+\",\"g\"),this.getTokenizer=function(){return this.$tokenizer||(this.$highlightRules=this.$highlightRules||new this.HighlightRules,this.$tokenizer=new r(this.$highlightRules.getRules())),this.$tokenizer},this.lineCommentStart=\"\",this.blockComment=\"\",this.toggleCommentLines=function(e,t,n,r){function w(e){for(var t=n;t<=r;t++)e(i.getLine(t),t)}var i=t.doc,s=!0,o=!0,a=Infinity,f=t.getTabSize(),l=!1;if(!this.lineCommentStart){if(!this.blockComment)return!1;var c=this.blockComment.start,h=this.blockComment.end,p=new RegExp(\"^(\\\\s*)(?:\"+u.escapeRegExp(c)+\")\"),d=new RegExp(\"(?:\"+u.escapeRegExp(h)+\")\\\\s*$\"),v=function(e,t){if(g(e,t))return;if(!s||/\\S/.test(e))i.insertInLine({row:t,column:e.length},h),i.insertInLine({row:t,column:a},c)},m=function(e,t){var n;(n=e.match(d))&&i.removeInLine(t,e.length-n[0].length,e.length),(n=e.match(p))&&i.removeInLine(t,n[1].length,n[0].length)},g=function(e,n){if(p.test(e))return!0;var r=t.getTokens(n);for(var i=0;i<r.length;i++)if(r[i].type===\"comment\")return!0}}else{if(Array.isArray(this.lineCommentStart))var p=this.lineCommentStart.map(u.escapeRegExp).join(\"|\"),c=this.lineCommentStart[0];else var p=u.escapeRegExp(this.lineCommentStart),c=this.lineCommentStart;p=new RegExp(\"^(\\\\s*)(?:\"+p+\") ?\"),l=t.getUseSoftTabs();var m=function(e,t){var n=e.match(p);if(!n)return;var r=n[1].length,s=n[0].length;!b(e,r,s)&&n[0][s-1]==\" \"&&s--,i.removeInLine(t,r,s)},y=c+\" \",v=function(e,t){if(!s||/\\S/.test(e))b(e,a,a)?i.insertInLine({row:t,column:a},y):i.insertInLine({row:t,column:a},c)},g=function(e,t){return p.test(e)},b=function(e,t,n){var r=0;while(t--&&e.charAt(t)==\" \")r++;if(r%f!=0)return!1;var r=0;while(e.charAt(n++)==\" \")r++;return f>2?r%f!=f-1:r%f==0}}var E=Infinity;w(function(e,t){var n=e.search(/\\S/);n!==-1?(n<a&&(a=n),o&&!g(e,t)&&(o=!1)):E>e.length&&(E=e.length)}),a==Infinity&&(a=E,s=!1,o=!1),l&&a%f!=0&&(a=Math.floor(a/f)*f),w(o?m:v)},this.toggleBlockComment=function(e,t,n,r){var i=this.blockComment;if(!i)return;!i.start&&i[0]&&(i=i[0]);var s=new a(t,r.row,r.column),o=s.getCurrentToken(),u=t.selection,l=t.selection.toOrientedRange(),c,h;if(o&&/comment/.test(o.type)){var p,d;while(o&&/comment/.test(o.type)){var v=o.value.indexOf(i.start);if(v!=-1){var m=s.getCurrentTokenRow(),g=s.getCurrentTokenColumn()+v;p=new f(m,g,m,g+i.start.length);break}o=s.stepBackward()}var s=new a(t,r.row,r.column),o=s.getCurrentToken();while(o&&/comment/.test(o.type)){var v=o.value.indexOf(i.end);if(v!=-1){var m=s.getCurrentTokenRow(),g=s.getCurrentTokenColumn()+v;d=new f(m,g,m,g+i.end.length);break}o=s.stepForward()}d&&t.remove(d),p&&(t.remove(p),c=p.start.row,h=-i.start.length)}else h=i.start.length,c=n.start.row,t.insert(n.end,i.end),t.insert(n.start,i.start);l.start.row==c&&(l.start.column+=h),l.end.row==c&&(l.end.column+=h),t.selection.fromOrientedRange(l)},this.getNextLineIndent=function(e,t,n){return this.$getIndent(t)},this.checkOutdent=function(e,t,n){return!1},this.autoOutdent=function(e,t,n){},this.$getIndent=function(e){return e.match(/^\\s*/)[0]},this.createWorker=function(e){return null},this.createModeDelegates=function(e){this.$embeds=[],this.$modes={};for(var t in e)e[t]&&(this.$embeds.push(t),this.$modes[t]=new e[t]);var n=[\"toggleBlockComment\",\"toggleCommentLines\",\"getNextLineIndent\",\"checkOutdent\",\"autoOutdent\",\"transformAction\",\"getCompletions\"];for(var t=0;t<n.length;t++)(function(e){var r=n[t],i=e[r];e[n[t]]=function(){return this.$delegator(r,arguments,i)}})(this)},this.$delegator=function(e,t,n){var r=t[0];typeof r!=\"string\"&&(r=r[0]);for(var i=0;i<this.$embeds.length;i++){if(!this.$modes[this.$embeds[i]])continue;var s=r.split(this.$embeds[i]);if(!s[0]&&s[1]){t[0]=s[1];var o=this.$modes[this.$embeds[i]];return o[e].apply(o,t)}}var u=n.apply(this,t);return n?u:undefined},this.transformAction=function(e,t,n,r,i){if(this.$behaviour){var s=this.$behaviour.getBehaviours();for(var o in s)if(s[o][t]){var u=s[o][t].apply(this,arguments);if(u)return u}}},this.getKeywords=function(e){if(!this.completionKeywords){var t=this.$tokenizer.rules,n=[];for(var r in t){var i=t[r];for(var s=0,o=i.length;s<o;s++)if(typeof i[s].token==\"string\")/keyword|support|storage/.test(i[s].token)&&n.push(i[s].regex);else if(typeof i[s].token==\"object\")for(var u=0,a=i[s].token.length;u<a;u++)if(/keyword|support|storage/.test(i[s].token[u])){var r=i[s].regex.match(/\\(.+?\\)/g)[u];n.push(r.substr(1,r.length-2))}}this.completionKeywords=n}return e?n.concat(this.$keywordList||[]):this.$keywordList},this.$createKeywordList=function(){return this.$highlightRules||this.getTokenizer(),this.$keywordList=this.$highlightRules.$keywordList||[]},this.getCompletions=function(e,t,n,r){var i=this.$keywordList||this.$createKeywordList();return i.map(function(e){return{name:e,value:e,score:0,meta:\"keyword\"}})},this.$id=\"ace/mode/text\"}).call(l.prototype),t.Mode=l}),define(\"ace/apply_delta\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";function r(e,t){throw console.log(\"Invalid Delta:\",e),\"Invalid Delta: \"+t}function i(e,t){return t.row>=0&&t.row<e.length&&t.column>=0&&t.column<=e[t.row].length}function s(e,t){t.action!=\"insert\"&&t.action!=\"remove\"&&r(t,\"delta.action must be 'insert' or 'remove'\"),t.lines instanceof Array||r(t,\"delta.lines must be an Array\"),(!t.start||!t.end)&&r(t,\"delta.start/end must be an present\");var n=t.start;i(e,t.start)||r(t,\"delta.start must be contained in document\");var s=t.end;t.action==\"remove\"&&!i(e,s)&&r(t,\"delta.end must contained in document for 'remove' actions\");var o=s.row-n.row,u=s.column-(o==0?n.column:0);(o!=t.lines.length-1||t.lines[o].length!=u)&&r(t,\"delta.range must match delta lines\")}t.applyDelta=function(e,t,n){var r=t.start.row,i=t.start.column,s=e[r]||\"\";switch(t.action){case\"insert\":var o=t.lines;if(o.length===1)e[r]=s.substring(0,i)+t.lines[0]+s.substring(i);else{var u=[r,1].concat(t.lines);e.splice.apply(e,u),e[r]=s.substring(0,i)+e[r],e[r+t.lines.length-1]+=s.substring(i)}break;case\"remove\":var a=t.end.column,f=t.end.row;r===f?e[r]=s.substring(0,i)+s.substring(a):e.splice(r,f-r+1,s.substring(0,i)+e[f].substring(a))}}}),define(\"ace/anchor\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/event_emitter\").EventEmitter,s=t.Anchor=function(e,t,n){this.$onChange=this.onChange.bind(this),this.attach(e),typeof n==\"undefined\"?this.setPosition(t.row,t.column):this.setPosition(t,n)};(function(){function e(e,t,n){var r=n?e.column<=t.column:e.column<t.column;return e.row<t.row||e.row==t.row&&r}function t(t,n,r){var i=t.action==\"insert\",s=(i?1:-1)*(t.end.row-t.start.row),o=(i?1:-1)*(t.end.column-t.start.column),u=t.start,a=i?u:t.end;return e(n,u,r)?{row:n.row,column:n.column}:e(a,n,!r)?{row:n.row+s,column:n.column+(n.row==a.row?o:0)}:{row:u.row,column:u.column}}r.implement(this,i),this.getPosition=function(){return this.$clipPositionToDocument(this.row,this.column)},this.getDocument=function(){return this.document},this.$insertRight=!1,this.onChange=function(e){if(e.start.row==e.end.row&&e.start.row!=this.row)return;if(e.start.row>this.row)return;var n=t(e,{row:this.row,column:this.column},this.$insertRight);this.setPosition(n.row,n.column,!0)},this.setPosition=function(e,t,n){var r;n?r={row:e,column:t}:r=this.$clipPositionToDocument(e,t);if(this.row==r.row&&this.column==r.column)return;var i={row:this.row,column:this.column};this.row=r.row,this.column=r.column,this._signal(\"change\",{old:i,value:r})},this.detach=function(){this.document.removeEventListener(\"change\",this.$onChange)},this.attach=function(e){this.document=e||this.document,this.document.on(\"change\",this.$onChange)},this.$clipPositionToDocument=function(e,t){var n={};return e>=this.document.getLength()?(n.row=Math.max(0,this.document.getLength()-1),n.column=this.document.getLine(n.row).length):e<0?(n.row=0,n.column=0):(n.row=e,n.column=Math.min(this.document.getLine(n.row).length,Math.max(0,t))),t<0&&(n.column=0),n}}).call(s.prototype)}),define(\"ace/document\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/apply_delta\",\"ace/lib/event_emitter\",\"ace/range\",\"ace/anchor\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./apply_delta\").applyDelta,s=e(\"./lib/event_emitter\").EventEmitter,o=e(\"./range\").Range,u=e(\"./anchor\").Anchor,a=function(e){this.$lines=[\"\"],e.length===0?this.$lines=[\"\"]:Array.isArray(e)?this.insertMergedLines({row:0,column:0},e):this.insert({row:0,column:0},e)};(function(){r.implement(this,s),this.setValue=function(e){var t=this.getLength()-1;this.remove(new o(0,0,t,this.getLine(t).length)),this.insert({row:0,column:0},e)},this.getValue=function(){return this.getAllLines().join(this.getNewLineCharacter())},this.createAnchor=function(e,t){return new u(this,e,t)},\"aaa\".split(/a/).length===0?this.$split=function(e){return e.replace(/\\r\\n|\\r/g,\"\\n\").split(\"\\n\")}:this.$split=function(e){return e.split(/\\r\\n|\\r|\\n/)},this.$detectNewLine=function(e){var t=e.match(/^.*?(\\r\\n|\\r|\\n)/m);this.$autoNewLine=t?t[1]:\"\\n\",this._signal(\"changeNewLineMode\")},this.getNewLineCharacter=function(){switch(this.$newLineMode){case\"windows\":return\"\\r\\n\";case\"unix\":return\"\\n\";default:return this.$autoNewLine||\"\\n\"}},this.$autoNewLine=\"\",this.$newLineMode=\"auto\",this.setNewLineMode=function(e){if(this.$newLineMode===e)return;this.$newLineMode=e,this._signal(\"changeNewLineMode\")},this.getNewLineMode=function(){return this.$newLineMode},this.isNewLine=function(e){return e==\"\\r\\n\"||e==\"\\r\"||e==\"\\n\"},this.getLine=function(e){return this.$lines[e]||\"\"},this.getLines=function(e,t){return this.$lines.slice(e,t+1)},this.getAllLines=function(){return this.getLines(0,this.getLength())},this.getLength=function(){return this.$lines.length},this.getTextRange=function(e){return this.getLinesForRange(e).join(this.getNewLineCharacter())},this.getLinesForRange=function(e){var t;if(e.start.row===e.end.row)t=[this.getLine(e.start.row).substring(e.start.column,e.end.column)];else{t=this.getLines(e.start.row,e.end.row),t[0]=(t[0]||\"\").substring(e.start.column);var n=t.length-1;e.end.row-e.start.row==n&&(t[n]=t[n].substring(0,e.end.column))}return t},this.insertLines=function(e,t){return console.warn(\"Use of document.insertLines is deprecated. Use the insertFullLines method instead.\"),this.insertFullLines(e,t)},this.removeLines=function(e,t){return console.warn(\"Use of document.removeLines is deprecated. Use the removeFullLines method instead.\"),this.removeFullLines(e,t)},this.insertNewLine=function(e){return console.warn(\"Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead.\"),this.insertMergedLines(e,[\"\",\"\"])},this.insert=function(e,t){return this.getLength()<=1&&this.$detectNewLine(t),this.insertMergedLines(e,this.$split(t))},this.insertInLine=function(e,t){var n=this.clippedPos(e.row,e.column),r=this.pos(e.row,e.column+t.length);return this.applyDelta({start:n,end:r,action:\"insert\",lines:[t]},!0),this.clonePos(r)},this.clippedPos=function(e,t){var n=this.getLength();e===undefined?e=n:e<0?e=0:e>=n&&(e=n-1,t=undefined);var r=this.getLine(e);return t==undefined&&(t=r.length),t=Math.min(Math.max(t,0),r.length),{row:e,column:t}},this.clonePos=function(e){return{row:e.row,column:e.column}},this.pos=function(e,t){return{row:e,column:t}},this.$clipPosition=function(e){var t=this.getLength();return e.row>=t?(e.row=Math.max(0,t-1),e.column=this.getLine(t-1).length):(e.row=Math.max(0,e.row),e.column=Math.min(Math.max(e.column,0),this.getLine(e.row).length)),e},this.insertFullLines=function(e,t){e=Math.min(Math.max(e,0),this.getLength());var n=0;e<this.getLength()?(t=t.concat([\"\"]),n=0):(t=[\"\"].concat(t),e--,n=this.$lines[e].length),this.insertMergedLines({row:e,column:n},t)},this.insertMergedLines=function(e,t){var n=this.clippedPos(e.row,e.column),r={row:n.row+t.length-1,column:(t.length==1?n.column:0)+t[t.length-1].length};return this.applyDelta({start:n,end:r,action:\"insert\",lines:t}),this.clonePos(r)},this.remove=function(e){var t=this.clippedPos(e.start.row,e.start.column),n=this.clippedPos(e.end.row,e.end.column);return this.applyDelta({start:t,end:n,action:\"remove\",lines:this.getLinesForRange({start:t,end:n})}),this.clonePos(t)},this.removeInLine=function(e,t,n){var r=this.clippedPos(e,t),i=this.clippedPos(e,n);return this.applyDelta({start:r,end:i,action:\"remove\",lines:this.getLinesForRange({start:r,end:i})},!0),this.clonePos(r)},this.removeFullLines=function(e,t){e=Math.min(Math.max(0,e),this.getLength()-1),t=Math.min(Math.max(0,t),this.getLength()-1);var n=t==this.getLength()-1&&e>0,r=t<this.getLength()-1,i=n?e-1:e,s=n?this.getLine(i).length:0,u=r?t+1:t,a=r?0:this.getLine(u).length,f=new o(i,s,u,a),l=this.$lines.slice(e,t+1);return this.applyDelta({start:f.start,end:f.end,action:\"remove\",lines:this.getLinesForRange(f)}),l},this.removeNewLine=function(e){e<this.getLength()-1&&e>=0&&this.applyDelta({start:this.pos(e,this.getLine(e).length),end:this.pos(e+1,0),action:\"remove\",lines:[\"\",\"\"]})},this.replace=function(e,t){!e instanceof o&&(e=o.fromPoints(e.start,e.end));if(t.length===0&&e.isEmpty())return e.start;if(t==this.getTextRange(e))return e.end;this.remove(e);var n;return t?n=this.insert(e.start,t):n=e.start,n},this.applyDeltas=function(e){for(var t=0;t<e.length;t++)this.applyDelta(e[t])},this.revertDeltas=function(e){for(var t=e.length-1;t>=0;t--)this.revertDelta(e[t])},this.applyDelta=function(e,t){var n=e.action==\"insert\";if(n?e.lines.length<=1&&!e.lines[0]:!o.comparePoints(e.start,e.end))return;n&&e.lines.length>2e4&&this.$splitAndapplyLargeDelta(e,2e4),i(this.$lines,e,t),this._signal(\"change\",e)},this.$splitAndapplyLargeDelta=function(e,t){var n=e.lines,r=n.length,i=e.start.row,s=e.start.column,o=0,u=0;do{o=u,u+=t-1;var a=n.slice(o,u);if(u>r){e.lines=a,e.start.row=i+o,e.start.column=s;break}a.push(\"\"),this.applyDelta({start:this.pos(i+o,s),end:this.pos(i+u,s=0),action:e.action,lines:a},!0)}while(!0)},this.revertDelta=function(e){this.applyDelta({start:this.clonePos(e.start),end:this.clonePos(e.end),action:e.action==\"insert\"?\"remove\":\"insert\",lines:e.lines.slice()})},this.indexToPosition=function(e,t){var n=this.$lines||this.getAllLines(),r=this.getNewLineCharacter().length;for(var i=t||0,s=n.length;i<s;i++){e-=n[i].length+r;if(e<0)return{row:i,column:e+n[i].length+r}}return{row:s-1,column:n[s-1].length}},this.positionToIndex=function(e,t){var n=this.$lines||this.getAllLines(),r=this.getNewLineCharacter().length,i=0,s=Math.min(e.row,n.length);for(var o=t||0;o<s;++o)i+=n[o].length+r;return i+e.column}}).call(a.prototype),t.Document=a}),define(\"ace/background_tokenizer\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/event_emitter\").EventEmitter,s=function(e,t){this.running=!1,this.lines=[],this.states=[],this.currentLine=0,this.tokenizer=e;var n=this;this.$worker=function(){if(!n.running)return;var e=new Date,t=n.currentLine,r=-1,i=n.doc,s=t;while(n.lines[t])t++;var o=i.getLength(),u=0;n.running=!1;while(t<o){n.$tokenizeRow(t),r=t;do t++;while(n.lines[t]);u++;if(u%5===0&&new Date-e>20){n.running=setTimeout(n.$worker,20);break}}n.currentLine=t,s<=r&&n.fireUpdateEvent(s,r)}};(function(){r.implement(this,i),this.setTokenizer=function(e){this.tokenizer=e,this.lines=[],this.states=[],this.start(0)},this.setDocument=function(e){this.doc=e,this.lines=[],this.states=[],this.stop()},this.fireUpdateEvent=function(e,t){var n={first:e,last:t};this._signal(\"update\",{data:n})},this.start=function(e){this.currentLine=Math.min(e||0,this.currentLine,this.doc.getLength()),this.lines.splice(this.currentLine,this.lines.length),this.states.splice(this.currentLine,this.states.length),this.stop(),this.running=setTimeout(this.$worker,700)},this.scheduleStart=function(){this.running||(this.running=setTimeout(this.$worker,700))},this.$updateOnChange=function(e){var t=e.start.row,n=e.end.row-t;if(n===0)this.lines[t]=null;else if(e.action==\"remove\")this.lines.splice(t,n+1,null),this.states.splice(t,n+1,null);else{var r=Array(n+1);r.unshift(t,1),this.lines.splice.apply(this.lines,r),this.states.splice.apply(this.states,r)}this.currentLine=Math.min(t,this.currentLine,this.doc.getLength()),this.stop()},this.stop=function(){this.running&&clearTimeout(this.running),this.running=!1},this.getTokens=function(e){return this.lines[e]||this.$tokenizeRow(e)},this.getState=function(e){return this.currentLine==e&&this.$tokenizeRow(e),this.states[e]||\"start\"},this.$tokenizeRow=function(e){var t=this.doc.getLine(e),n=this.states[e-1],r=this.tokenizer.getLineTokens(t,n,e);return this.states[e]+\"\"!=r.state+\"\"?(this.states[e]=r.state,this.lines[e+1]=null,this.currentLine>e+1&&(this.currentLine=e+1)):this.currentLine==e&&(this.currentLine=e+1),this.lines[e]=r.tokens}}).call(s.prototype),t.BackgroundTokenizer=s}),define(\"ace/search_highlight\",[\"require\",\"exports\",\"module\",\"ace/lib/lang\",\"ace/lib/oop\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"./lib/lang\"),i=e(\"./lib/oop\"),s=e(\"./range\").Range,o=function(e,t,n){this.setRegexp(e),this.clazz=t,this.type=n||\"text\"};(function(){this.MAX_RANGES=500,this.setRegexp=function(e){if(this.regExp+\"\"==e+\"\")return;this.regExp=e,this.cache=[]},this.update=function(e,t,n,i){if(!this.regExp)return;var o=i.firstRow,u=i.lastRow;for(var a=o;a<=u;a++){var f=this.cache[a];f==null&&(f=r.getMatchOffsets(n.getLine(a),this.regExp),f.length>this.MAX_RANGES&&(f=f.slice(0,this.MAX_RANGES)),f=f.map(function(e){return new s(a,e.offset,a,e.offset+e.length)}),this.cache[a]=f.length?f:\"\");for(var l=f.length;l--;)t.drawSingleLineMarker(e,f[l].toScreenRange(n),this.clazz,i)}}}).call(o.prototype),t.SearchHighlight=o}),define(\"ace/edit_session/fold_line\",[\"require\",\"exports\",\"module\",\"ace/range\"],function(e,t,n){\"use strict\";function i(e,t){this.foldData=e,Array.isArray(t)?this.folds=t:t=this.folds=[t];var n=t[t.length-1];this.range=new r(t[0].start.row,t[0].start.column,n.end.row,n.end.column),this.start=this.range.start,this.end=this.range.end,this.folds.forEach(function(e){e.setFoldLine(this)},this)}var r=e(\"../range\").Range;(function(){this.shiftRow=function(e){this.start.row+=e,this.end.row+=e,this.folds.forEach(function(t){t.start.row+=e,t.end.row+=e})},this.addFold=function(e){if(e.sameRow){if(e.start.row<this.startRow||e.endRow>this.endRow)throw new Error(\"Can't add a fold to this FoldLine as it has no connection\");this.folds.push(e),this.folds.sort(function(e,t){return-e.range.compareEnd(t.start.row,t.start.column)}),this.range.compareEnd(e.start.row,e.start.column)>0?(this.end.row=e.end.row,this.end.column=e.end.column):this.range.compareStart(e.end.row,e.end.column)<0&&(this.start.row=e.start.row,this.start.column=e.start.column)}else if(e.start.row==this.end.row)this.folds.push(e),this.end.row=e.end.row,this.end.column=e.end.column;else{if(e.end.row!=this.start.row)throw new Error(\"Trying to add fold to FoldRow that doesn't have a matching row\");this.folds.unshift(e),this.start.row=e.start.row,this.start.column=e.start.column}e.foldLine=this},this.containsRow=function(e){return e>=this.start.row&&e<=this.end.row},this.walk=function(e,t,n){var r=0,i=this.folds,s,o,u,a=!0;t==null&&(t=this.end.row,n=this.end.column);for(var f=0;f<i.length;f++){s=i[f],o=s.range.compareStart(t,n);if(o==-1){e(null,t,n,r,a);return}u=e(null,s.start.row,s.start.column,r,a),u=!u&&e(s.placeholder,s.start.row,s.start.column,r);if(u||o===0)return;a=!s.sameRow,r=s.end.column}e(null,t,n,r,a)},this.getNextFoldTo=function(e,t){var n,r;for(var i=0;i<this.folds.length;i++){n=this.folds[i],r=n.range.compareEnd(e,t);if(r==-1)return{fold:n,kind:\"after\"};if(r===0)return{fold:n,kind:\"inside\"}}return null},this.addRemoveChars=function(e,t,n){var r=this.getNextFoldTo(e,t),i,s;if(r){i=r.fold;if(r.kind==\"inside\"&&i.start.column!=t&&i.start.row!=e)window.console&&window.console.log(e,t,i);else if(i.start.row==e){s=this.folds;var o=s.indexOf(i);o===0&&(this.start.column+=n);for(o;o<s.length;o++){i=s[o],i.start.column+=n;if(!i.sameRow)return;i.end.column+=n}this.end.column+=n}}},this.split=function(e,t){var n=this.getNextFoldTo(e,t);if(!n||n.kind==\"inside\")return null;var r=n.fold,s=this.folds,o=this.foldData,u=s.indexOf(r),a=s[u-1];this.end.row=a.end.row,this.end.column=a.end.column,s=s.splice(u,s.length-u);var f=new i(o,s);return o.splice(o.indexOf(this)+1,0,f),f},this.merge=function(e){var t=e.folds;for(var n=0;n<t.length;n++)this.addFold(t[n]);var r=this.foldData;r.splice(r.indexOf(e),1)},this.toString=function(){var e=[this.range.toString()+\": [\"];return this.folds.forEach(function(t){e.push(\"  \"+t.toString())}),e.push(\"]\"),e.join(\"\\n\")},this.idxToPosition=function(e){var t=0;for(var n=0;n<this.folds.length;n++){var r=this.folds[n];e-=r.start.column-t;if(e<0)return{row:r.start.row,column:r.start.column+e};e-=r.placeholder.length;if(e<0)return r.start;t=r.end.column}return{row:this.end.row,column:this.end.column+e}}}).call(i.prototype),t.FoldLine=i}),define(\"ace/range_list\",[\"require\",\"exports\",\"module\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"./range\").Range,i=r.comparePoints,s=function(){this.ranges=[]};(function(){this.comparePoints=i,this.pointIndex=function(e,t,n){var r=this.ranges;for(var s=n||0;s<r.length;s++){var o=r[s],u=i(e,o.end);if(u>0)continue;var a=i(e,o.start);return u===0?t&&a!==0?-s-2:s:a>0||a===0&&!t?s:-s-1}return-s-1},this.add=function(e){var t=!e.isEmpty(),n=this.pointIndex(e.start,t);n<0&&(n=-n-1);var r=this.pointIndex(e.end,t,n);return r<0?r=-r-1:r++,this.ranges.splice(n,r-n,e)},this.addList=function(e){var t=[];for(var n=e.length;n--;)t.push.call(t,this.add(e[n]));return t},this.substractPoint=function(e){var t=this.pointIndex(e);if(t>=0)return this.ranges.splice(t,1)},this.merge=function(){var e=[],t=this.ranges;t=t.sort(function(e,t){return i(e.start,t.start)});var n=t[0],r;for(var s=1;s<t.length;s++){r=n,n=t[s];var o=i(r.end,n.start);if(o<0)continue;if(o==0&&!r.isEmpty()&&!n.isEmpty())continue;i(r.end,n.end)<0&&(r.end.row=n.end.row,r.end.column=n.end.column),t.splice(s,1),e.push(n),n=r,s--}return this.ranges=t,e},this.contains=function(e,t){return this.pointIndex({row:e,column:t})>=0},this.containsPoint=function(e){return this.pointIndex(e)>=0},this.rangeAtPoint=function(e){var t=this.pointIndex(e);if(t>=0)return this.ranges[t]},this.clipRows=function(e,t){var n=this.ranges;if(n[0].start.row>t||n[n.length-1].start.row<e)return[];var r=this.pointIndex({row:e,column:0});r<0&&(r=-r-1);var i=this.pointIndex({row:t,column:0},r);i<0&&(i=-i-1);var s=[];for(var o=r;o<i;o++)s.push(n[o]);return s},this.removeAll=function(){return this.ranges.splice(0,this.ranges.length)},this.attach=function(e){this.session&&this.detach(),this.session=e,this.onChange=this.$onChange.bind(this),this.session.on(\"change\",this.onChange)},this.detach=function(){if(!this.session)return;this.session.removeListener(\"change\",this.onChange),this.session=null},this.$onChange=function(e){if(e.action==\"insert\")var t=e.start,n=e.end;else var n=e.start,t=e.end;var r=t.row,i=n.row,s=i-r,o=-t.column+n.column,u=this.ranges;for(var a=0,f=u.length;a<f;a++){var l=u[a];if(l.end.row<r)continue;if(l.start.row>r)break;l.start.row==r&&l.start.column>=t.column&&(l.start.column!=t.column||!this.$insertRight)&&(l.start.column+=o,l.start.row+=s);if(l.end.row==r&&l.end.column>=t.column){if(l.end.column==t.column&&this.$insertRight)continue;l.end.column==t.column&&o>0&&a<f-1&&l.end.column>l.start.column&&l.end.column==u[a+1].start.column&&(l.end.column-=o),l.end.column+=o,l.end.row+=s}}if(s!=0&&a<f)for(;a<f;a++){var l=u[a];l.start.row+=s,l.end.row+=s}}}).call(s.prototype),t.RangeList=s}),define(\"ace/edit_session/fold\",[\"require\",\"exports\",\"module\",\"ace/range\",\"ace/range_list\",\"ace/lib/oop\"],function(e,t,n){\"use strict\";function u(e,t){e.row-=t.row,e.row==0&&(e.column-=t.column)}function a(e,t){u(e.start,t),u(e.end,t)}function f(e,t){e.row==0&&(e.column+=t.column),e.row+=t.row}function l(e,t){f(e.start,t),f(e.end,t)}var r=e(\"../range\").Range,i=e(\"../range_list\").RangeList,s=e(\"../lib/oop\"),o=t.Fold=function(e,t){this.foldLine=null,this.placeholder=t,this.range=e,this.start=e.start,this.end=e.end,this.sameRow=e.start.row==e.end.row,this.subFolds=this.ranges=[]};s.inherits(o,i),function(){this.toString=function(){return'\"'+this.placeholder+'\" '+this.range.toString()},this.setFoldLine=function(e){this.foldLine=e,this.subFolds.forEach(function(t){t.setFoldLine(e)})},this.clone=function(){var e=this.range.clone(),t=new o(e,this.placeholder);return this.subFolds.forEach(function(e){t.subFolds.push(e.clone())}),t.collapseChildren=this.collapseChildren,t},this.addSubFold=function(e){if(this.range.isEqual(e))return;if(!this.range.containsRange(e))throw new Error(\"A fold can't intersect already existing fold\"+e.range+this.range);a(e,this.start);var t=e.start.row,n=e.start.column;for(var r=0,i=-1;r<this.subFolds.length;r++){i=this.subFolds[r].range.compare(t,n);if(i!=1)break}var s=this.subFolds[r];if(i==0)return s.addSubFold(e);var t=e.range.end.row,n=e.range.end.column;for(var o=r,i=-1;o<this.subFolds.length;o++){i=this.subFolds[o].range.compare(t,n);if(i!=1)break}var u=this.subFolds[o];if(i==0)throw new Error(\"A fold can't intersect already existing fold\"+e.range+this.range);var f=this.subFolds.splice(r,o-r,e);return e.setFoldLine(this.foldLine),e},this.restoreRange=function(e){return l(e,this.start)}}.call(o.prototype)}),define(\"ace/edit_session/folding\",[\"require\",\"exports\",\"module\",\"ace/range\",\"ace/edit_session/fold_line\",\"ace/edit_session/fold\",\"ace/token_iterator\"],function(e,t,n){\"use strict\";function u(){this.getFoldAt=function(e,t,n){var r=this.getFoldLine(e);if(!r)return null;var i=r.folds;for(var s=0;s<i.length;s++){var o=i[s];if(o.range.contains(e,t)){if(n==1&&o.range.isEnd(e,t))continue;if(n==-1&&o.range.isStart(e,t))continue;return o}}},this.getFoldsInRange=function(e){var t=e.start,n=e.end,r=this.$foldData,i=[];t.column+=1,n.column-=1;for(var s=0;s<r.length;s++){var o=r[s].range.compareRange(e);if(o==2)continue;if(o==-2)break;var u=r[s].folds;for(var a=0;a<u.length;a++){var f=u[a];o=f.range.compareRange(e);if(o==-2)break;if(o==2)continue;if(o==42)break;i.push(f)}}return t.column-=1,n.column+=1,i},this.getFoldsInRangeList=function(e){if(Array.isArray(e)){var t=[];e.forEach(function(e){t=t.concat(this.getFoldsInRange(e))},this)}else var t=this.getFoldsInRange(e);return t},this.getAllFolds=function(){var e=[],t=this.$foldData;for(var n=0;n<t.length;n++)for(var r=0;r<t[n].folds.length;r++)e.push(t[n].folds[r]);return e},this.getFoldStringAt=function(e,t,n,r){r=r||this.getFoldLine(e);if(!r)return null;var i={end:{column:0}},s,o;for(var u=0;u<r.folds.length;u++){o=r.folds[u];var a=o.range.compareEnd(e,t);if(a==-1){s=this.getLine(o.start.row).substring(i.end.column,o.start.column);break}if(a===0)return null;i=o}return s||(s=this.getLine(o.start.row).substring(i.end.column)),n==-1?s.substring(0,t-i.end.column):n==1?s.substring(t-i.end.column):s},this.getFoldLine=function(e,t){var n=this.$foldData,r=0;t&&(r=n.indexOf(t)),r==-1&&(r=0);for(r;r<n.length;r++){var i=n[r];if(i.start.row<=e&&i.end.row>=e)return i;if(i.end.row>e)return null}return null},this.getNextFoldLine=function(e,t){var n=this.$foldData,r=0;t&&(r=n.indexOf(t)),r==-1&&(r=0);for(r;r<n.length;r++){var i=n[r];if(i.end.row>=e)return i}return null},this.getFoldedRowCount=function(e,t){var n=this.$foldData,r=t-e+1;for(var i=0;i<n.length;i++){var s=n[i],o=s.end.row,u=s.start.row;if(o>=t){u<t&&(u>=e?r-=t-u:r=0);break}o>=e&&(u>=e?r-=o-u:r-=o-e+1)}return r},this.$addFoldLine=function(e){return this.$foldData.push(e),this.$foldData.sort(function(e,t){return e.start.row-t.start.row}),e},this.addFold=function(e,t){var n=this.$foldData,r=!1,o;e instanceof s?o=e:(o=new s(t,e),o.collapseChildren=t.collapseChildren),this.$clipRangeToDocument(o.range);var u=o.start.row,a=o.start.column,f=o.end.row,l=o.end.column;if(u<f||u==f&&a<=l-2){var c=this.getFoldAt(u,a,1),h=this.getFoldAt(f,l,-1);if(c&&h==c)return c.addSubFold(o);c&&!c.range.isStart(u,a)&&this.removeFold(c),h&&!h.range.isEnd(f,l)&&this.removeFold(h);var p=this.getFoldsInRange(o.range);p.length>0&&(this.removeFolds(p),p.forEach(function(e){o.addSubFold(e)}));for(var d=0;d<n.length;d++){var v=n[d];if(f==v.start.row){v.addFold(o),r=!0;break}if(u==v.end.row){v.addFold(o),r=!0;if(!o.sameRow){var m=n[d+1];if(m&&m.start.row==f){v.merge(m);break}}break}if(f<=v.start.row)break}return r||(v=this.$addFoldLine(new i(this.$foldData,o))),this.$useWrapMode?this.$updateWrapData(v.start.row,v.start.row):this.$updateRowLengthCache(v.start.row,v.start.row),this.$modified=!0,this._emit(\"changeFold\",{data:o,action:\"add\"}),o}throw new Error(\"The range has to be at least 2 characters width\")},this.addFolds=function(e){e.forEach(function(e){this.addFold(e)},this)},this.removeFold=function(e){var t=e.foldLine,n=t.start.row,r=t.end.row,i=this.$foldData,s=t.folds;if(s.length==1)i.splice(i.indexOf(t),1);else if(t.range.isEnd(e.end.row,e.end.column))s.pop(),t.end.row=s[s.length-1].end.row,t.end.column=s[s.length-1].end.column;else if(t.range.isStart(e.start.row,e.start.column))s.shift(),t.start.row=s[0].start.row,t.start.column=s[0].start.column;else if(e.sameRow)s.splice(s.indexOf(e),1);else{var o=t.split(e.start.row,e.start.column);s=o.folds,s.shift(),o.start.row=s[0].start.row,o.start.column=s[0].start.column}this.$updating||(this.$useWrapMode?this.$updateWrapData(n,r):this.$updateRowLengthCache(n,r)),this.$modified=!0,this._emit(\"changeFold\",{data:e,action:\"remove\"})},this.removeFolds=function(e){var t=[];for(var n=0;n<e.length;n++)t.push(e[n]);t.forEach(function(e){this.removeFold(e)},this),this.$modified=!0},this.expandFold=function(e){this.removeFold(e),e.subFolds.forEach(function(t){e.restoreRange(t),this.addFold(t)},this),e.collapseChildren>0&&this.foldAll(e.start.row+1,e.end.row,e.collapseChildren-1),e.subFolds=[]},this.expandFolds=function(e){e.forEach(function(e){this.expandFold(e)},this)},this.unfold=function(e,t){var n,i;e==null?(n=new r(0,0,this.getLength(),0),t=!0):typeof e==\"number\"?n=new r(e,0,e,this.getLine(e).length):\"row\"in e?n=r.fromPoints(e,e):n=e,i=this.getFoldsInRangeList(n);if(t)this.removeFolds(i);else{var s=i;while(s.length)this.expandFolds(s),s=this.getFoldsInRangeList(n)}if(i.length)return i},this.isRowFolded=function(e,t){return!!this.getFoldLine(e,t)},this.getRowFoldEnd=function(e,t){var n=this.getFoldLine(e,t);return n?n.end.row:e},this.getRowFoldStart=function(e,t){var n=this.getFoldLine(e,t);return n?n.start.row:e},this.getFoldDisplayLine=function(e,t,n,r,i){r==null&&(r=e.start.row),i==null&&(i=0),t==null&&(t=e.end.row),n==null&&(n=this.getLine(t).length);var s=this.doc,o=\"\";return e.walk(function(e,t,n,u){if(t<r)return;if(t==r){if(n<i)return;u=Math.max(i,u)}e!=null?o+=e:o+=s.getLine(t).substring(u,n)},t,n),o},this.getDisplayLine=function(e,t,n,r){var i=this.getFoldLine(e);if(!i){var s;return s=this.doc.getLine(e),s.substring(r||0,t||s.length)}return this.getFoldDisplayLine(i,e,t,n,r)},this.$cloneFoldData=function(){var e=[];return e=this.$foldData.map(function(t){var n=t.folds.map(function(e){return e.clone()});return new i(e,n)}),e},this.toggleFold=function(e){var t=this.selection,n=t.getRange(),r,i;if(n.isEmpty()){var s=n.start;r=this.getFoldAt(s.row,s.column);if(r){this.expandFold(r);return}(i=this.findMatchingBracket(s))?n.comparePoint(i)==1?n.end=i:(n.start=i,n.start.column++,n.end.column--):(i=this.findMatchingBracket({row:s.row,column:s.column+1}))?(n.comparePoint(i)==1?n.end=i:n.start=i,n.start.column++):n=this.getCommentFoldRange(s.row,s.column)||n}else{var o=this.getFoldsInRange(n);if(e&&o.length){this.expandFolds(o);return}o.length==1&&(r=o[0])}r||(r=this.getFoldAt(n.start.row,n.start.column));if(r&&r.range.toString()==n.toString()){this.expandFold(r);return}var u=\"...\";if(!n.isMultiLine()){u=this.getTextRange(n);if(u.length<4)return;u=u.trim().substring(0,2)+\"..\"}this.addFold(u,n)},this.getCommentFoldRange=function(e,t,n){var i=new o(this,e,t),s=i.getCurrentToken();if(s&&/^comment|string/.test(s.type)){var u=new r,a=new RegExp(s.type.replace(/\\..*/,\"\\\\.\"));if(n!=1){do s=i.stepBackward();while(s&&a.test(s.type));i.stepForward()}u.start.row=i.getCurrentTokenRow(),u.start.column=i.getCurrentTokenColumn()+2,i=new o(this,e,t);if(n!=-1){do s=i.stepForward();while(s&&a.test(s.type));s=i.stepBackward()}else s=i.getCurrentToken();return u.end.row=i.getCurrentTokenRow(),u.end.column=i.getCurrentTokenColumn()+s.value.length-2,u}},this.foldAll=function(e,t,n){n==undefined&&(n=1e5);var r=this.foldWidgets;if(!r)return;t=t||this.getLength(),e=e||0;for(var i=e;i<t;i++){r[i]==null&&(r[i]=this.getFoldWidget(i));if(r[i]!=\"start\")continue;var s=this.getFoldWidgetRange(i);if(s&&s.isMultiLine()&&s.end.row<=t&&s.start.row>=e){i=s.end.row;try{var o=this.addFold(\"...\",s);o&&(o.collapseChildren=n)}catch(u){}}}},this.$foldStyles={manual:1,markbegin:1,markbeginend:1},this.$foldStyle=\"markbegin\",this.setFoldStyle=function(e){if(!this.$foldStyles[e])throw new Error(\"invalid fold style: \"+e+\"[\"+Object.keys(this.$foldStyles).join(\", \")+\"]\");if(this.$foldStyle==e)return;this.$foldStyle=e,e==\"manual\"&&this.unfold();var t=this.$foldMode;this.$setFolding(null),this.$setFolding(t)},this.$setFolding=function(e){if(this.$foldMode==e)return;this.$foldMode=e,this.off(\"change\",this.$updateFoldWidgets),this.off(\"tokenizerUpdate\",this.$tokenizerUpdateFoldWidgets),this._emit(\"changeAnnotation\");if(!e||this.$foldStyle==\"manual\"){this.foldWidgets=null;return}this.foldWidgets=[],this.getFoldWidget=e.getFoldWidget.bind(e,this,this.$foldStyle),this.getFoldWidgetRange=e.getFoldWidgetRange.bind(e,this,this.$foldStyle),this.$updateFoldWidgets=this.updateFoldWidgets.bind(this),this.$tokenizerUpdateFoldWidgets=this.tokenizerUpdateFoldWidgets.bind(this),this.on(\"change\",this.$updateFoldWidgets),this.on(\"tokenizerUpdate\",this.$tokenizerUpdateFoldWidgets)},this.getParentFoldRangeData=function(e,t){var n=this.foldWidgets;if(!n||t&&n[e])return{};var r=e-1,i;while(r>=0){var s=n[r];s==null&&(s=n[r]=this.getFoldWidget(r));if(s==\"start\"){var o=this.getFoldWidgetRange(r);i||(i=o);if(o&&o.end.row>=e)break}r--}return{range:r!==-1&&o,firstRange:i}},this.onFoldWidgetClick=function(e,t){t=t.domEvent;var n={children:t.shiftKey,all:t.ctrlKey||t.metaKey,siblings:t.altKey},r=this.$toggleFoldWidget(e,n);if(!r){var i=t.target||t.srcElement;i&&/ace_fold-widget/.test(i.className)&&(i.className+=\" ace_invalid\")}},this.$toggleFoldWidget=function(e,t){if(!this.getFoldWidget)return;var n=this.getFoldWidget(e),r=this.getLine(e),i=n===\"end\"?-1:1,s=this.getFoldAt(e,i===-1?0:r.length,i);if(s){t.children||t.all?this.removeFold(s):this.expandFold(s);return}var o=this.getFoldWidgetRange(e,!0);if(o&&!o.isMultiLine()){s=this.getFoldAt(o.start.row,o.start.column,1);if(s&&o.isEqual(s.range)){this.removeFold(s);return}}if(t.siblings){var u=this.getParentFoldRangeData(e);if(u.range)var a=u.range.start.row+1,f=u.range.end.row;this.foldAll(a,f,t.all?1e4:0)}else t.children?(f=o?o.end.row:this.getLength(),this.foldAll(e+1,f,t.all?1e4:0)):o&&(t.all&&(o.collapseChildren=1e4),this.addFold(\"...\",o));return o},this.toggleFoldWidget=function(e){var t=this.selection.getCursor().row;t=this.getRowFoldStart(t);var n=this.$toggleFoldWidget(t,{});if(n)return;var r=this.getParentFoldRangeData(t,!0);n=r.range||r.firstRange;if(n){t=n.start.row;var i=this.getFoldAt(t,this.getLine(t).length,1);i?this.removeFold(i):this.addFold(\"...\",n)}},this.updateFoldWidgets=function(e){var t=e.start.row,n=e.end.row-t;if(n===0)this.foldWidgets[t]=null;else if(e.action==\"remove\")this.foldWidgets.splice(t,n+1,null);else{var r=Array(n+1);r.unshift(t,1),this.foldWidgets.splice.apply(this.foldWidgets,r)}},this.tokenizerUpdateFoldWidgets=function(e){var t=e.data;t.first!=t.last&&this.foldWidgets.length>t.first&&this.foldWidgets.splice(t.first,this.foldWidgets.length)}}var r=e(\"../range\").Range,i=e(\"./fold_line\").FoldLine,s=e(\"./fold\").Fold,o=e(\"../token_iterator\").TokenIterator;t.Folding=u}),define(\"ace/edit_session/bracket_match\",[\"require\",\"exports\",\"module\",\"ace/token_iterator\",\"ace/range\"],function(e,t,n){\"use strict\";function s(){this.findMatchingBracket=function(e,t){if(e.column==0)return null;var n=t||this.getLine(e.row).charAt(e.column-1);if(n==\"\")return null;var r=n.match(/([\\(\\[\\{])|([\\)\\]\\}])/);return r?r[1]?this.$findClosingBracket(r[1],e):this.$findOpeningBracket(r[2],e):null},this.getBracketRange=function(e){var t=this.getLine(e.row),n=!0,r,s=t.charAt(e.column-1),o=s&&s.match(/([\\(\\[\\{])|([\\)\\]\\}])/);o||(s=t.charAt(e.column),e={row:e.row,column:e.column+1},o=s&&s.match(/([\\(\\[\\{])|([\\)\\]\\}])/),n=!1);if(!o)return null;if(o[1]){var u=this.$findClosingBracket(o[1],e);if(!u)return null;r=i.fromPoints(e,u),n||(r.end.column++,r.start.column--),r.cursor=r.end}else{var u=this.$findOpeningBracket(o[2],e);if(!u)return null;r=i.fromPoints(u,e),n||(r.start.column++,r.end.column--),r.cursor=r.start}return r},this.$brackets={\")\":\"(\",\"(\":\")\",\"]\":\"[\",\"[\":\"]\",\"{\":\"}\",\"}\":\"{\"},this.$findOpeningBracket=function(e,t,n){var i=this.$brackets[e],s=1,o=new r(this,t.row,t.column),u=o.getCurrentToken();u||(u=o.stepForward());if(!u)return;n||(n=new RegExp(\"(\\\\.?\"+u.type.replace(\".\",\"\\\\.\").replace(\"rparen\",\".paren\").replace(/\\b(?:end)\\b/,\"(?:start|begin|end)\")+\")+\"));var a=t.column-o.getCurrentTokenColumn()-2,f=u.value;for(;;){while(a>=0){var l=f.charAt(a);if(l==i){s-=1;if(s==0)return{row:o.getCurrentTokenRow(),column:a+o.getCurrentTokenColumn()}}else l==e&&(s+=1);a-=1}do u=o.stepBackward();while(u&&!n.test(u.type));if(u==null)break;f=u.value,a=f.length-1}return null},this.$findClosingBracket=function(e,t,n){var i=this.$brackets[e],s=1,o=new r(this,t.row,t.column),u=o.getCurrentToken();u||(u=o.stepForward());if(!u)return;n||(n=new RegExp(\"(\\\\.?\"+u.type.replace(\".\",\"\\\\.\").replace(\"lparen\",\".paren\").replace(/\\b(?:start|begin)\\b/,\"(?:start|begin|end)\")+\")+\"));var a=t.column-o.getCurrentTokenColumn();for(;;){var f=u.value,l=f.length;while(a<l){var c=f.charAt(a);if(c==i){s-=1;if(s==0)return{row:o.getCurrentTokenRow(),column:a+o.getCurrentTokenColumn()}}else c==e&&(s+=1);a+=1}do u=o.stepForward();while(u&&!n.test(u.type));if(u==null)break;a=0}return null}}var r=e(\"../token_iterator\").TokenIterator,i=e(\"../range\").Range;t.BracketMatch=s}),define(\"ace/edit_session\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/config\",\"ace/lib/event_emitter\",\"ace/selection\",\"ace/mode/text\",\"ace/range\",\"ace/document\",\"ace/background_tokenizer\",\"ace/search_highlight\",\"ace/edit_session/folding\",\"ace/edit_session/bracket_match\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/lang\"),s=e(\"./config\"),o=e(\"./lib/event_emitter\").EventEmitter,u=e(\"./selection\").Selection,a=e(\"./mode/text\").Mode,f=e(\"./range\").Range,l=e(\"./document\").Document,c=e(\"./background_tokenizer\").BackgroundTokenizer,h=e(\"./search_highlight\").SearchHighlight,p=function(e,t){this.$breakpoints=[],this.$decorations=[],this.$frontMarkers={},this.$backMarkers={},this.$markerId=1,this.$undoSelect=!0,this.$foldData=[],this.$foldData.toString=function(){return this.join(\"\\n\")},this.on(\"changeFold\",this.onChangeFold.bind(this)),this.$onChange=this.onChange.bind(this);if(typeof e!=\"object\"||!e.getLine)e=new l(e);this.setDocument(e),this.selection=new u(this),s.resetOptions(this),this.setMode(t),s._signal(\"session\",this)};(function(){function m(e){return e<4352?!1:e>=4352&&e<=4447||e>=4515&&e<=4519||e>=4602&&e<=4607||e>=9001&&e<=9002||e>=11904&&e<=11929||e>=11931&&e<=12019||e>=12032&&e<=12245||e>=12272&&e<=12283||e>=12288&&e<=12350||e>=12353&&e<=12438||e>=12441&&e<=12543||e>=12549&&e<=12589||e>=12593&&e<=12686||e>=12688&&e<=12730||e>=12736&&e<=12771||e>=12784&&e<=12830||e>=12832&&e<=12871||e>=12880&&e<=13054||e>=13056&&e<=19903||e>=19968&&e<=42124||e>=42128&&e<=42182||e>=43360&&e<=43388||e>=44032&&e<=55203||e>=55216&&e<=55238||e>=55243&&e<=55291||e>=63744&&e<=64255||e>=65040&&e<=65049||e>=65072&&e<=65106||e>=65108&&e<=65126||e>=65128&&e<=65131||e>=65281&&e<=65376||e>=65504&&e<=65510}r.implement(this,o),this.setDocument=function(e){this.doc&&this.doc.removeListener(\"change\",this.$onChange),this.doc=e,e.on(\"change\",this.$onChange),this.bgTokenizer&&this.bgTokenizer.setDocument(this.getDocument()),this.resetCaches()},this.getDocument=function(){return this.doc},this.$resetRowCache=function(e){if(!e){this.$docRowCache=[],this.$screenRowCache=[];return}var t=this.$docRowCache.length,n=this.$getRowCacheIndex(this.$docRowCache,e)+1;t>n&&(this.$docRowCache.splice(n,t),this.$screenRowCache.splice(n,t))},this.$getRowCacheIndex=function(e,t){var n=0,r=e.length-1;while(n<=r){var i=n+r>>1,s=e[i];if(t>s)n=i+1;else{if(!(t<s))return i;r=i-1}}return n-1},this.resetCaches=function(){this.$modified=!0,this.$wrapData=[],this.$rowLengthCache=[],this.$resetRowCache(0),this.bgTokenizer&&this.bgTokenizer.start(0)},this.onChangeFold=function(e){var t=e.data;this.$resetRowCache(t.start.row)},this.onChange=function(e){this.$modified=!0,this.$resetRowCache(e.start.row);var t=this.$updateInternalDataOnChange(e);!this.$fromUndo&&this.$undoManager&&!e.ignore&&(this.$deltasDoc.push(e),t&&t.length!=0&&this.$deltasFold.push({action:\"removeFolds\",folds:t}),this.$informUndoManager.schedule()),this.bgTokenizer&&this.bgTokenizer.$updateOnChange(e),this._signal(\"change\",e)},this.setValue=function(e){this.doc.setValue(e),this.selection.moveTo(0,0),this.$resetRowCache(0),this.$deltas=[],this.$deltasDoc=[],this.$deltasFold=[],this.setUndoManager(this.$undoManager),this.getUndoManager().reset()},this.getValue=this.toString=function(){return this.doc.getValue()},this.getSelection=function(){return this.selection},this.getState=function(e){return this.bgTokenizer.getState(e)},this.getTokens=function(e){return this.bgTokenizer.getTokens(e)},this.getTokenAt=function(e,t){var n=this.bgTokenizer.getTokens(e),r,i=0;if(t==null)s=n.length-1,i=this.getLine(e).length;else for(var s=0;s<n.length;s++){i+=n[s].value.length;if(i>=t)break}return r=n[s],r?(r.index=s,r.start=i-r.value.length,r):null},this.setUndoManager=function(e){this.$undoManager=e,this.$deltas=[],this.$deltasDoc=[],this.$deltasFold=[],this.$informUndoManager&&this.$informUndoManager.cancel();if(e){var t=this;this.$syncInformUndoManager=function(){t.$informUndoManager.cancel(),t.$deltasFold.length&&(t.$deltas.push({group:\"fold\",deltas:t.$deltasFold}),t.$deltasFold=[]),t.$deltasDoc.length&&(t.$deltas.push({group:\"doc\",deltas:t.$deltasDoc}),t.$deltasDoc=[]),t.$deltas.length>0&&e.execute({action:\"aceupdate\",args:[t.$deltas,t],merge:t.mergeUndoDeltas}),t.mergeUndoDeltas=!1,t.$deltas=[]},this.$informUndoManager=i.delayedCall(this.$syncInformUndoManager)}},this.markUndoGroup=function(){this.$syncInformUndoManager&&this.$syncInformUndoManager()},this.$defaultUndoManager={undo:function(){},redo:function(){},reset:function(){}},this.getUndoManager=function(){return this.$undoManager||this.$defaultUndoManager},this.getTabString=function(){return this.getUseSoftTabs()?i.stringRepeat(\" \",this.getTabSize()):\"\t\"},this.setUseSoftTabs=function(e){this.setOption(\"useSoftTabs\",e)},this.getUseSoftTabs=function(){return this.$useSoftTabs&&!this.$mode.$indentWithTabs},this.setTabSize=function(e){this.setOption(\"tabSize\",e)},this.getTabSize=function(){return this.$tabSize},this.isTabStop=function(e){return this.$useSoftTabs&&e.column%this.$tabSize===0},this.$overwrite=!1,this.setOverwrite=function(e){this.setOption(\"overwrite\",e)},this.getOverwrite=function(){return this.$overwrite},this.toggleOverwrite=function(){this.setOverwrite(!this.$overwrite)},this.addGutterDecoration=function(e,t){this.$decorations[e]||(this.$decorations[e]=\"\"),this.$decorations[e]+=\" \"+t,this._signal(\"changeBreakpoint\",{})},this.removeGutterDecoration=function(e,t){this.$decorations[e]=(this.$decorations[e]||\"\").replace(\" \"+t,\"\"),this._signal(\"changeBreakpoint\",{})},this.getBreakpoints=function(){return this.$breakpoints},this.setBreakpoints=function(e){this.$breakpoints=[];for(var t=0;t<e.length;t++)this.$breakpoints[e[t]]=\"ace_breakpoint\";this._signal(\"changeBreakpoint\",{})},this.clearBreakpoints=function(){this.$breakpoints=[],this._signal(\"changeBreakpoint\",{})},this.setBreakpoint=function(e,t){t===undefined&&(t=\"ace_breakpoint\"),t?this.$breakpoints[e]=t:delete this.$breakpoints[e],this._signal(\"changeBreakpoint\",{})},this.clearBreakpoint=function(e){delete this.$breakpoints[e],this._signal(\"changeBreakpoint\",{})},this.addMarker=function(e,t,n,r){var i=this.$markerId++,s={range:e,type:n||\"line\",renderer:typeof n==\"function\"?n:null,clazz:t,inFront:!!r,id:i};return r?(this.$frontMarkers[i]=s,this._signal(\"changeFrontMarker\")):(this.$backMarkers[i]=s,this._signal(\"changeBackMarker\")),i},this.addDynamicMarker=function(e,t){if(!e.update)return;var n=this.$markerId++;return e.id=n,e.inFront=!!t,t?(this.$frontMarkers[n]=e,this._signal(\"changeFrontMarker\")):(this.$backMarkers[n]=e,this._signal(\"changeBackMarker\")),e},this.removeMarker=function(e){var t=this.$frontMarkers[e]||this.$backMarkers[e];if(!t)return;var n=t.inFront?this.$frontMarkers:this.$backMarkers;t&&(delete n[e],this._signal(t.inFront?\"changeFrontMarker\":\"changeBackMarker\"))},this.getMarkers=function(e){return e?this.$frontMarkers:this.$backMarkers},this.highlight=function(e){if(!this.$searchHighlight){var t=new h(null,\"ace_selected-word\",\"text\");this.$searchHighlight=this.addDynamicMarker(t)}this.$searchHighlight.setRegexp(e)},this.highlightLines=function(e,t,n,r){typeof t!=\"number\"&&(n=t,t=e),n||(n=\"ace_step\");var i=new f(e,0,t,Infinity);return i.id=this.addMarker(i,n,\"fullLine\",r),i},this.setAnnotations=function(e){this.$annotations=e,this._signal(\"changeAnnotation\",{})},this.getAnnotations=function(){return this.$annotations||[]},this.clearAnnotations=function(){this.setAnnotations([])},this.$detectNewLine=function(e){var t=e.match(/^.*?(\\r?\\n)/m);t?this.$autoNewLine=t[1]:this.$autoNewLine=\"\\n\"},this.getWordRange=function(e,t){var n=this.getLine(e),r=!1;t>0&&(r=!!n.charAt(t-1).match(this.tokenRe)),r||(r=!!n.charAt(t).match(this.tokenRe));if(r)var i=this.tokenRe;else if(/^\\s+$/.test(n.slice(t-1,t+1)))var i=/\\s/;else var i=this.nonTokenRe;var s=t;if(s>0){do s--;while(s>=0&&n.charAt(s).match(i));s++}var o=t;while(o<n.length&&n.charAt(o).match(i))o++;return new f(e,s,e,o)},this.getAWordRange=function(e,t){var n=this.getWordRange(e,t),r=this.getLine(n.end.row);while(r.charAt(n.end.column).match(/[ \\t]/))n.end.column+=1;return n},this.setNewLineMode=function(e){this.doc.setNewLineMode(e)},this.getNewLineMode=function(){return this.doc.getNewLineMode()},this.setUseWorker=function(e){this.setOption(\"useWorker\",e)},this.getUseWorker=function(){return this.$useWorker},this.onReloadTokenizer=function(e){var t=e.data;this.bgTokenizer.start(t.first),this._signal(\"tokenizerUpdate\",e)},this.$modes={},this.$mode=null,this.$modeId=null,this.setMode=function(e,t){if(e&&typeof e==\"object\"){if(e.getTokenizer)return this.$onChangeMode(e);var n=e,r=n.path}else r=e||\"ace/mode/text\";this.$modes[\"ace/mode/text\"]||(this.$modes[\"ace/mode/text\"]=new a);if(this.$modes[r]&&!n){this.$onChangeMode(this.$modes[r]),t&&t();return}this.$modeId=r,s.loadModule([\"mode\",r],function(e){if(this.$modeId!==r)return t&&t();this.$modes[r]&&!n?this.$onChangeMode(this.$modes[r]):e&&e.Mode&&(e=new e.Mode(n),n||(this.$modes[r]=e,e.$id=r),this.$onChangeMode(e)),t&&t()}.bind(this)),this.$mode||this.$onChangeMode(this.$modes[\"ace/mode/text\"],!0)},this.$onChangeMode=function(e,t){t||(this.$modeId=e.$id);if(this.$mode===e)return;this.$mode=e,this.$stopWorker(),this.$useWorker&&this.$startWorker();var n=e.getTokenizer();if(n.addEventListener!==undefined){var r=this.onReloadTokenizer.bind(this);n.addEventListener(\"update\",r)}if(!this.bgTokenizer){this.bgTokenizer=new c(n);var i=this;this.bgTokenizer.addEventListener(\"update\",function(e){i._signal(\"tokenizerUpdate\",e)})}else this.bgTokenizer.setTokenizer(n);this.bgTokenizer.setDocument(this.getDocument()),this.tokenRe=e.tokenRe,this.nonTokenRe=e.nonTokenRe,t||(e.attachToSession&&e.attachToSession(this),this.$options.wrapMethod.set.call(this,this.$wrapMethod),this.$setFolding(e.foldingRules),this.bgTokenizer.start(0),this._emit(\"changeMode\"))},this.$stopWorker=function(){this.$worker&&(this.$worker.terminate(),this.$worker=null)},this.$startWorker=function(){try{this.$worker=this.$mode.createWorker(this)}catch(e){s.warn(\"Could not load worker\",e),this.$worker=null}},this.getMode=function(){return this.$mode},this.$scrollTop=0,this.setScrollTop=function(e){if(this.$scrollTop===e||isNaN(e))return;this.$scrollTop=e,this._signal(\"changeScrollTop\",e)},this.getScrollTop=function(){return this.$scrollTop},this.$scrollLeft=0,this.setScrollLeft=function(e){if(this.$scrollLeft===e||isNaN(e))return;this.$scrollLeft=e,this._signal(\"changeScrollLeft\",e)},this.getScrollLeft=function(){return this.$scrollLeft},this.getScreenWidth=function(){return this.$computeWidth(),this.lineWidgets?Math.max(this.getLineWidgetMaxWidth(),this.screenWidth):this.screenWidth},this.getLineWidgetMaxWidth=function(){if(this.lineWidgetsWidth!=null)return this.lineWidgetsWidth;var e=0;return this.lineWidgets.forEach(function(t){t&&t.screenWidth>e&&(e=t.screenWidth)}),this.lineWidgetWidth=e},this.$computeWidth=function(e){if(this.$modified||e){this.$modified=!1;if(this.$useWrapMode)return this.screenWidth=this.$wrapLimit;var t=this.doc.getAllLines(),n=this.$rowLengthCache,r=0,i=0,s=this.$foldData[i],o=s?s.start.row:Infinity,u=t.length;for(var a=0;a<u;a++){if(a>o){a=s.end.row+1;if(a>=u)break;s=this.$foldData[i++],o=s?s.start.row:Infinity}n[a]==null&&(n[a]=this.$getStringScreenWidth(t[a])[0]),n[a]>r&&(r=n[a])}this.screenWidth=r}},this.getLine=function(e){return this.doc.getLine(e)},this.getLines=function(e,t){return this.doc.getLines(e,t)},this.getLength=function(){return this.doc.getLength()},this.getTextRange=function(e){return this.doc.getTextRange(e||this.selection.getRange())},this.insert=function(e,t){return this.doc.insert(e,t)},this.remove=function(e){return this.doc.remove(e)},this.removeFullLines=function(e,t){return this.doc.removeFullLines(e,t)},this.undoChanges=function(e,t){if(!e.length)return;this.$fromUndo=!0;var n=null;for(var r=e.length-1;r!=-1;r--){var i=e[r];i.group==\"doc\"?(this.doc.revertDeltas(i.deltas),n=this.$getUndoSelection(i.deltas,!0,n)):i.deltas.forEach(function(e){this.addFolds(e.folds)},this)}return this.$fromUndo=!1,n&&this.$undoSelect&&!t&&this.selection.setSelectionRange(n),n},this.redoChanges=function(e,t){if(!e.length)return;this.$fromUndo=!0;var n=null;for(var r=0;r<e.length;r++){var i=e[r];i.group==\"doc\"&&(this.doc.applyDeltas(i.deltas),n=this.$getUndoSelection(i.deltas,!1,n))}return this.$fromUndo=!1,n&&this.$undoSelect&&!t&&this.selection.setSelectionRange(n),n},this.setUndoSelect=function(e){this.$undoSelect=e},this.$getUndoSelection=function(e,t,n){function r(e){return t?e.action!==\"insert\":e.action===\"insert\"}var i=e[0],s,o,u=!1;r(i)?(s=f.fromPoints(i.start,i.end),u=!0):(s=f.fromPoints(i.start,i.start),u=!1);for(var a=1;a<e.length;a++)i=e[a],r(i)?(o=i.start,s.compare(o.row,o.column)==-1&&s.setStart(o),o=i.end,s.compare(o.row,o.column)==1&&s.setEnd(o),u=!0):(o=i.start,s.compare(o.row,o.column)==-1&&(s=f.fromPoints(i.start,i.start)),u=!1);if(n!=null){f.comparePoints(n.start,s.start)===0&&(n.start.column+=s.end.column-s.start.column,n.end.column+=s.end.column-s.start.column);var l=n.compareRange(s);l==1?s.setStart(n.start):l==-1&&s.setEnd(n.end)}return s},this.replace=function(e,t){return this.doc.replace(e,t)},this.moveText=function(e,t,n){var r=this.getTextRange(e),i=this.getFoldsInRange(e),s=f.fromPoints(t,t);if(!n){this.remove(e);var o=e.start.row-e.end.row,u=o?-e.end.column:e.start.column-e.end.column;u&&(s.start.row==e.end.row&&s.start.column>e.end.column&&(s.start.column+=u),s.end.row==e.end.row&&s.end.column>e.end.column&&(s.end.column+=u)),o&&s.start.row>=e.end.row&&(s.start.row+=o,s.end.row+=o)}s.end=this.insert(s.start,r);if(i.length){var a=e.start,l=s.start,o=l.row-a.row,u=l.column-a.column;this.addFolds(i.map(function(e){return e=e.clone(),e.start.row==a.row&&(e.start.column+=u),e.end.row==a.row&&(e.end.column+=u),e.start.row+=o,e.end.row+=o,e}))}return s},this.indentRows=function(e,t,n){n=n.replace(/\\t/g,this.getTabString());for(var r=e;r<=t;r++)this.doc.insertInLine({row:r,column:0},n)},this.outdentRows=function(e){var t=e.collapseRows(),n=new f(0,0,0,0),r=this.getTabSize();for(var i=t.start.row;i<=t.end.row;++i){var s=this.getLine(i);n.start.row=i,n.end.row=i;for(var o=0;o<r;++o)if(s.charAt(o)!=\" \")break;o<r&&s.charAt(o)==\"\t\"?(n.start.column=o,n.end.column=o+1):(n.start.column=0,n.end.column=o),this.remove(n)}},this.$moveLines=function(e,t,n){e=this.getRowFoldStart(e),t=this.getRowFoldEnd(t);if(n<0){var r=this.getRowFoldStart(e+n);if(r<0)return 0;var i=r-e}else if(n>0){var r=this.getRowFoldEnd(t+n);if(r>this.doc.getLength()-1)return 0;var i=r-t}else{e=this.$clipRowToDocument(e),t=this.$clipRowToDocument(t);var i=t-e+1}var s=new f(e,0,t,Number.MAX_VALUE),o=this.getFoldsInRange(s).map(function(e){return e=e.clone(),e.start.row+=i,e.end.row+=i,e}),u=n==0?this.doc.getLines(e,t):this.doc.removeFullLines(e,t);return this.doc.insertFullLines(e+i,u),o.length&&this.addFolds(o),i},this.moveLinesUp=function(e,t){return this.$moveLines(e,t,-1)},this.moveLinesDown=function(e,t){return this.$moveLines(e,t,1)},this.duplicateLines=function(e,t){return this.$moveLines(e,t,0)},this.$clipRowToDocument=function(e){return Math.max(0,Math.min(e,this.doc.getLength()-1))},this.$clipColumnToRow=function(e,t){return t<0?0:Math.min(this.doc.getLine(e).length,t)},this.$clipPositionToDocument=function(e,t){t=Math.max(0,t);if(e<0)e=0,t=0;else{var n=this.doc.getLength();e>=n?(e=n-1,t=this.doc.getLine(n-1).length):t=Math.min(this.doc.getLine(e).length,t)}return{row:e,column:t}},this.$clipRangeToDocument=function(e){e.start.row<0?(e.start.row=0,e.start.column=0):e.start.column=this.$clipColumnToRow(e.start.row,e.start.column);var t=this.doc.getLength()-1;return e.end.row>t?(e.end.row=t,e.end.column=this.doc.getLine(t).length):e.end.column=this.$clipColumnToRow(e.end.row,e.end.column),e},this.$wrapLimit=80,this.$useWrapMode=!1,this.$wrapLimitRange={min:null,max:null},this.setUseWrapMode=function(e){if(e!=this.$useWrapMode){this.$useWrapMode=e,this.$modified=!0,this.$resetRowCache(0);if(e){var t=this.getLength();this.$wrapData=Array(t),this.$updateWrapData(0,t-1)}this._signal(\"changeWrapMode\")}},this.getUseWrapMode=function(){return this.$useWrapMode},this.setWrapLimitRange=function(e,t){if(this.$wrapLimitRange.min!==e||this.$wrapLimitRange.max!==t)this.$wrapLimitRange={min:e,max:t},this.$modified=!0,this.$useWrapMode&&this._signal(\"changeWrapMode\")},this.adjustWrapLimit=function(e,t){var n=this.$wrapLimitRange;n.max<0&&(n={min:t,max:t});var r=this.$constrainWrapLimit(e,n.min,n.max);return r!=this.$wrapLimit&&r>1?(this.$wrapLimit=r,this.$modified=!0,this.$useWrapMode&&(this.$updateWrapData(0,this.getLength()-1),this.$resetRowCache(0),this._signal(\"changeWrapLimit\")),!0):!1},this.$constrainWrapLimit=function(e,t,n){return t&&(e=Math.max(t,e)),n&&(e=Math.min(n,e)),e},this.getWrapLimit=function(){return this.$wrapLimit},this.setWrapLimit=function(e){this.setWrapLimitRange(e,e)},this.getWrapLimitRange=function(){return{min:this.$wrapLimitRange.min,max:this.$wrapLimitRange.max}},this.$updateInternalDataOnChange=function(e){var t=this.$useWrapMode,n=e.action,r=e.start,i=e.end,s=r.row,o=i.row,u=o-s,a=null;this.$updating=!0;if(u!=0)if(n===\"remove\"){this[t?\"$wrapData\":\"$rowLengthCache\"].splice(s,u);var f=this.$foldData;a=this.getFoldsInRange(e),this.removeFolds(a);var l=this.getFoldLine(i.row),c=0;if(l){l.addRemoveChars(i.row,i.column,r.column-i.column),l.shiftRow(-u);var h=this.getFoldLine(s);h&&h!==l&&(h.merge(l),l=h),c=f.indexOf(l)+1}for(c;c<f.length;c++){var l=f[c];l.start.row>=i.row&&l.shiftRow(-u)}o=s}else{var p=Array(u);p.unshift(s,0);var d=t?this.$wrapData:this.$rowLengthCache;d.splice.apply(d,p);var f=this.$foldData,l=this.getFoldLine(s),c=0;if(l){var v=l.range.compareInside(r.row,r.column);v==0?(l=l.split(r.row,r.column),l&&(l.shiftRow(u),l.addRemoveChars(o,0,i.column-r.column))):v==-1&&(l.addRemoveChars(s,0,i.column-r.column),l.shiftRow(u)),c=f.indexOf(l)+1}for(c;c<f.length;c++){var l=f[c];l.start.row>=s&&l.shiftRow(u)}}else{u=Math.abs(e.start.column-e.end.column),n===\"remove\"&&(a=this.getFoldsInRange(e),this.removeFolds(a),u=-u);var l=this.getFoldLine(s);l&&l.addRemoveChars(s,r.column,u)}return t&&this.$wrapData.length!=this.doc.getLength()&&console.error(\"doc.getLength() and $wrapData.length have to be the same!\"),this.$updating=!1,t?this.$updateWrapData(s,o):this.$updateRowLengthCache(s,o),a},this.$updateRowLengthCache=function(e,t,n){this.$rowLengthCache[e]=null,this.$rowLengthCache[t]=null},this.$updateWrapData=function(e,t){var r=this.doc.getAllLines(),i=this.getTabSize(),s=this.$wrapData,o=this.$wrapLimit,a,f,l=e;t=Math.min(t,r.length-1);while(l<=t)f=this.getFoldLine(l,f),f?(a=[],f.walk(function(e,t,i,s){var o;if(e!=null){o=this.$getDisplayTokens(e,a.length),o[0]=n;for(var f=1;f<o.length;f++)o[f]=u}else o=this.$getDisplayTokens(r[t].substring(s,i),a.length);a=a.concat(o)}.bind(this),f.end.row,r[f.end.row].length+1),s[f.start.row]=this.$computeWrapSplits(a,o,i),l=f.end.row+1):(a=this.$getDisplayTokens(r[l]),s[l]=this.$computeWrapSplits(a,o,i),l++)};var e=1,t=2,n=3,u=4,l=9,p=10,d=11,v=12;this.$computeWrapSplits=function(e,r,i){function g(){var t=0;if(m===0)return t;if(h)for(var n=0;n<e.length;n++){var r=e[n];if(r==p)t+=1;else{if(r!=d){if(r==v)continue;break}t+=i}}return c&&h!==!1&&(t+=i),Math.min(t,m)}function y(t){var n=e.slice(a,t),r=n.length;n.join(\"\").replace(/12/g,function(){r-=1}).replace(/2/g,function(){r-=1}),s.length||(b=g(),s.indent=b),f+=r,s.push(f),a=t}if(e.length==0)return[];var s=[],o=e.length,a=0,f=0,c=this.$wrapAsCode,h=this.$indentedSoftWrap,m=r<=Math.max(2*i,8)||h===!1?0:Math.floor(r/2),b=0;while(o-a>r-b){var w=a+r-b;if(e[w-1]>=p&&e[w]>=p){y(w);continue}if(e[w]==n||e[w]==u){for(w;w!=a-1;w--)if(e[w]==n)break;if(w>a){y(w);continue}w=a+r;for(w;w<e.length;w++)if(e[w]!=u)break;if(w==e.length)break;y(w);continue}var E=Math.max(w-(r-(r>>2)),a-1);while(w>E&&e[w]<n)w--;if(c){while(w>E&&e[w]<n)w--;while(w>E&&e[w]==l)w--}else while(w>E&&e[w]<p)w--;if(w>E){y(++w);continue}w=a+r,e[w]==t&&w--,y(w-b)}return s},this.$getDisplayTokens=function(n,r){var i=[],s;r=r||0;for(var o=0;o<n.length;o++){var u=n.charCodeAt(o);if(u==9){s=this.getScreenTabSize(i.length+r),i.push(d);for(var a=1;a<s;a++)i.push(v)}else u==32?i.push(p):u>39&&u<48||u>57&&u<64?i.push(l):u>=4352&&m(u)?i.push(e,t):i.push(e)}return i},this.$getStringScreenWidth=function(e,t,n){if(t==0)return[0,0];t==null&&(t=Infinity),n=n||0;var r,i;for(i=0;i<e.length;i++){r=e.charCodeAt(i),r==9?n+=this.getScreenTabSize(n):r>=4352&&m(r)?n+=2:n+=1;if(n>t)break}return[n,i]},this.lineWidgets=null,this.getRowLength=function(e){if(this.lineWidgets)var t=this.lineWidgets[e]&&this.lineWidgets[e].rowCount||0;else t=0;return!this.$useWrapMode||!this.$wrapData[e]?1+t:this.$wrapData[e].length+1+t},this.getRowLineCount=function(e){return!this.$useWrapMode||!this.$wrapData[e]?1:this.$wrapData[e].length+1},this.getRowWrapIndent=function(e){if(this.$useWrapMode){var t=this.screenToDocumentPosition(e,Number.MAX_VALUE),n=this.$wrapData[t.row];return n.length&&n[0]<t.column?n.indent:0}return 0},this.getScreenLastRowColumn=function(e){var t=this.screenToDocumentPosition(e,Number.MAX_VALUE);return this.documentToScreenColumn(t.row,t.column)},this.getDocumentLastRowColumn=function(e,t){var n=this.documentToScreenRow(e,t);return this.getScreenLastRowColumn(n)},this.getDocumentLastRowColumnPosition=function(e,t){var n=this.documentToScreenRow(e,t);return this.screenToDocumentPosition(n,Number.MAX_VALUE/10)},this.getRowSplitData=function(e){return this.$useWrapMode?this.$wrapData[e]:undefined},this.getScreenTabSize=function(e){return this.$tabSize-e%this.$tabSize},this.screenToDocumentRow=function(e,t){return this.screenToDocumentPosition(e,t).row},this.screenToDocumentColumn=function(e,t){return this.screenToDocumentPosition(e,t).column},this.screenToDocumentPosition=function(e,t){if(e<0)return{row:0,column:0};var n,r=0,i=0,s,o=0,u=0,a=this.$screenRowCache,f=this.$getRowCacheIndex(a,e),l=a.length;if(l&&f>=0)var o=a[f],r=this.$docRowCache[f],c=e>a[l-1];else var c=!l;var h=this.getLength()-1,p=this.getNextFoldLine(r),d=p?p.start.row:Infinity;while(o<=e){u=this.getRowLength(r);if(o+u>e||r>=h)break;o+=u,r++,r>d&&(r=p.end.row+1,p=this.getNextFoldLine(r,p),d=p?p.start.row:Infinity),c&&(this.$docRowCache.push(r),this.$screenRowCache.push(o))}if(p&&p.start.row<=r)n=this.getFoldDisplayLine(p),r=p.start.row;else{if(o+u<=e||r>h)return{row:h,column:this.getLine(h).length};n=this.getLine(r),p=null}var v=0;if(this.$useWrapMode){var m=this.$wrapData[r];if(m){var g=Math.floor(e-o);s=m[g],g>0&&m.length&&(v=m.indent,i=m[g-1]||m[m.length-1],n=n.substring(i))}}return i+=this.$getStringScreenWidth(n,t-v)[1],this.$useWrapMode&&i>=s&&(i=s-1),p?p.idxToPosition(i):{row:r,column:i}},this.documentToScreenPosition=function(e,t){if(typeof t==\"undefined\")var n=this.$clipPositionToDocument(e.row,e.column);else n=this.$clipPositionToDocument(e,t);e=n.row,t=n.column;var r=0,i=null,s=null;s=this.getFoldAt(e,t,1),s&&(e=s.start.row,t=s.start.column);var o,u=0,a=this.$docRowCache,f=this.$getRowCacheIndex(a,e),l=a.length;if(l&&f>=0)var u=a[f],r=this.$screenRowCache[f],c=e>a[l-1];else var c=!l;var h=this.getNextFoldLine(u),p=h?h.start.row:Infinity;while(u<e){if(u>=p){o=h.end.row+1;if(o>e)break;h=this.getNextFoldLine(o,h),p=h?h.start.row:Infinity}else o=u+1;r+=this.getRowLength(u),u=o,c&&(this.$docRowCache.push(u),this.$screenRowCache.push(r))}var d=\"\";h&&u>=p?(d=this.getFoldDisplayLine(h,e,t),i=h.start.row):(d=this.getLine(e).substring(0,t),i=e);var v=0;if(this.$useWrapMode){var m=this.$wrapData[i];if(m){var g=0;while(d.length>=m[g])r++,g++;d=d.substring(m[g-1]||0,d.length),v=g>0?m.indent:0}}return{row:r,column:v+this.$getStringScreenWidth(d)[0]}},this.documentToScreenColumn=function(e,t){return this.documentToScreenPosition(e,t).column},this.documentToScreenRow=function(e,t){return this.documentToScreenPosition(e,t).row},this.getScreenLength=function(){var e=0,t=null;if(!this.$useWrapMode){e=this.getLength();var n=this.$foldData;for(var r=0;r<n.length;r++)t=n[r],e-=t.end.row-t.start.row}else{var i=this.$wrapData.length,s=0,r=0,t=this.$foldData[r++],o=t?t.start.row:Infinity;while(s<i){var u=this.$wrapData[s];e+=u?u.length+1:1,s++,s>o&&(s=t.end.row+1,t=this.$foldData[r++],o=t?t.start.row:Infinity)}}return this.lineWidgets&&(e+=this.$getWidgetScreenLength()),e},this.$setFontMetrics=function(e){},this.destroy=function(){this.bgTokenizer&&(this.bgTokenizer.setDocument(null),this.bgTokenizer=null),this.$stopWorker()}}).call(p.prototype),e(\"./edit_session/folding\").Folding.call(p.prototype),e(\"./edit_session/bracket_match\").BracketMatch.call(p.prototype),s.defineOptions(p.prototype,\"session\",{wrap:{set:function(e){!e||e==\"off\"?e=!1:e==\"free\"?e=!0:e==\"printMargin\"?e=-1:typeof e==\"string\"&&(e=parseInt(e,10)||!1);if(this.$wrap==e)return;this.$wrap=e;if(!e)this.setUseWrapMode(!1);else{var t=typeof e==\"number\"?e:null;this.setWrapLimitRange(t,t),this.setUseWrapMode(!0)}},get:function(){return this.getUseWrapMode()?this.$wrap==-1?\"printMargin\":this.getWrapLimitRange().min?this.$wrap:\"free\":\"off\"},handlesSet:!0},wrapMethod:{set:function(e){e=e==\"auto\"?this.$mode.type!=\"text\":e!=\"text\",e!=this.$wrapAsCode&&(this.$wrapAsCode=e,this.$useWrapMode&&(this.$modified=!0,this.$resetRowCache(0),this.$updateWrapData(0,this.getLength()-1)))},initialValue:\"auto\"},indentedSoftWrap:{initialValue:!0},firstLineNumber:{set:function(){this._signal(\"changeBreakpoint\")},initialValue:1},useWorker:{set:function(e){this.$useWorker=e,this.$stopWorker(),e&&this.$startWorker()},initialValue:!0},useSoftTabs:{initialValue:!0},tabSize:{set:function(e){if(isNaN(e)||this.$tabSize===e)return;this.$modified=!0,this.$rowLengthCache=[],this.$tabSize=e,this._signal(\"changeTabSize\")},initialValue:4,handlesSet:!0},overwrite:{set:function(e){this._signal(\"changeOverwrite\")},initialValue:!1},newLineMode:{set:function(e){this.doc.setNewLineMode(e)},get:function(){return this.doc.getNewLineMode()},handlesSet:!0},mode:{set:function(e){this.setMode(e)},get:function(){return this.$modeId}}}),t.EditSession=p}),define(\"ace/search\",[\"require\",\"exports\",\"module\",\"ace/lib/lang\",\"ace/lib/oop\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"./lib/lang\"),i=e(\"./lib/oop\"),s=e(\"./range\").Range,o=function(){this.$options={}};(function(){this.set=function(e){return i.mixin(this.$options,e),this},this.getOptions=function(){return r.copyObject(this.$options)},this.setOptions=function(e){this.$options=e},this.find=function(e){var t=this.$options,n=this.$matchIterator(e,t);if(!n)return!1;var r=null;return n.forEach(function(e,n,i){if(!e.start){var o=e.offset+(i||0);r=new s(n,o,n,o+e.length);if(!e.length&&t.start&&t.start.start&&t.skipCurrent!=0&&r.isEqual(t.start))return r=null,!1}else r=e;return!0}),r},this.findAll=function(e){var t=this.$options;if(!t.needle)return[];this.$assembleRegExp(t);var n=t.range,i=n?e.getLines(n.start.row,n.end.row):e.doc.getAllLines(),o=[],u=t.re;if(t.$isMultiLine){var a=u.length,f=i.length-a,l;e:for(var c=u.offset||0;c<=f;c++){for(var h=0;h<a;h++)if(i[c+h].search(u[h])==-1)continue e;var p=i[c],d=i[c+a-1],v=p.length-p.match(u[0])[0].length,m=d.match(u[a-1])[0].length;if(l&&l.end.row===c&&l.end.column>v)continue;o.push(l=new s(c,v,c+a-1,m)),a>2&&(c=c+a-2)}}else for(var g=0;g<i.length;g++){var y=r.getMatchOffsets(i[g],u);for(var h=0;h<y.length;h++){var b=y[h];o.push(new s(g,b.offset,g,b.offset+b.length))}}if(n){var w=n.start.column,E=n.start.column,g=0,h=o.length-1;while(g<h&&o[g].start.column<w&&o[g].start.row==n.start.row)g++;while(g<h&&o[h].end.column>E&&o[h].end.row==n.end.row)h--;o=o.slice(g,h+1);for(g=0,h=o.length;g<h;g++)o[g].start.row+=n.start.row,o[g].end.row+=n.start.row}return o},this.replace=function(e,t){var n=this.$options,r=this.$assembleRegExp(n);if(n.$isMultiLine)return t;if(!r)return;var i=r.exec(e);if(!i||i[0].length!=e.length)return null;t=e.replace(r,t);if(n.preserveCase){t=t.split(\"\");for(var s=Math.min(e.length,e.length);s--;){var o=e[s];o&&o.toLowerCase()!=o?t[s]=t[s].toUpperCase():t[s]=t[s].toLowerCase()}t=t.join(\"\")}return t},this.$matchIterator=function(e,t){var n=this.$assembleRegExp(t);if(!n)return!1;var i;if(t.$isMultiLine)var o=n.length,u=function(t,r,u){var a=t.search(n[0]);if(a==-1)return;for(var f=1;f<o;f++){t=e.getLine(r+f);if(t.search(n[f])==-1)return}var l=t.match(n[o-1])[0].length,c=new s(r,a,r+o-1,l);n.offset==1?(c.start.row--,c.start.column=Number.MAX_VALUE):u&&(c.start.column+=u);if(i(c))return!0};else if(t.backwards)var u=function(e,t,s){var o=r.getMatchOffsets(e,n);for(var u=o.length-1;u>=0;u--)if(i(o[u],t,s))return!0};else var u=function(e,t,s){var o=r.getMatchOffsets(e,n);for(var u=0;u<o.length;u++)if(i(o[u],t,s))return!0};var a=this.$lineIterator(e,t);return{forEach:function(e){i=e,a.forEach(u)}}},this.$assembleRegExp=function(e,t){if(e.needle instanceof RegExp)return e.re=e.needle;var n=e.needle;if(!e.needle)return e.re=!1;e.regExp||(n=r.escapeRegExp(n)),e.wholeWord&&(n=\"\\\\b\"+n+\"\\\\b\");var i=e.caseSensitive?\"gm\":\"gmi\";e.$isMultiLine=!t&&/[\\n\\r]/.test(n);if(e.$isMultiLine)return e.re=this.$assembleMultilineRegExp(n,i);try{var s=new RegExp(n,i)}catch(o){s=!1}return e.re=s},this.$assembleMultilineRegExp=function(e,t){var n=e.replace(/\\r\\n|\\r|\\n/g,\"$\\n^\").split(\"\\n\"),r=[];for(var i=0;i<n.length;i++)try{r.push(new RegExp(n[i],t))}catch(s){return!1}return n[0]==\"\"?(r.shift(),r.offset=1):r.offset=0,r},this.$lineIterator=function(e,t){var n=t.backwards==1,r=t.skipCurrent!=0,i=t.range,s=t.start;s||(s=i?i[n?\"end\":\"start\"]:e.selection.getRange()),s.start&&(s=s[r!=n?\"end\":\"start\"]);var o=i?i.start.row:0,u=i?i.end.row:e.getLength()-1,a=n?function(n){var r=s.row,i=e.getLine(r).substring(0,s.column);if(n(i,r))return;for(r--;r>=o;r--)if(n(e.getLine(r),r))return;if(t.wrap==0)return;for(r=u,o=s.row;r>=o;r--)if(n(e.getLine(r),r))return}:function(n){var r=s.row,i=e.getLine(r).substr(s.column);if(n(i,r,s.column))return;for(r+=1;r<=u;r++)if(n(e.getLine(r),r))return;if(t.wrap==0)return;for(r=o,u=s.row;r<=u;r++)if(n(e.getLine(r),r))return};return{forEach:a}}}).call(o.prototype),t.Search=o}),define(\"ace/keyboard/hash_handler\",[\"require\",\"exports\",\"module\",\"ace/lib/keys\",\"ace/lib/useragent\"],function(e,t,n){\"use strict\";function o(e,t){this.platform=t||(i.isMac?\"mac\":\"win\"),this.commands={},this.commandKeyBinding={},this.addCommands(e),this.$singleCommand=!0}function u(e,t){o.call(this,e,t),this.$singleCommand=!1}var r=e(\"../lib/keys\"),i=e(\"../lib/useragent\"),s=r.KEY_MODS;u.prototype=o.prototype,function(){function e(e){return typeof e==\"object\"&&e.bindKey&&e.bindKey.position||0}this.addCommand=function(e){this.commands[e.name]&&this.removeCommand(e),this.commands[e.name]=e,e.bindKey&&this._buildKeyHash(e)},this.removeCommand=function(e,t){var n=e&&(typeof e==\"string\"?e:e.name);e=this.commands[n],t||delete this.commands[n];var r=this.commandKeyBinding;for(var i in r){var s=r[i];if(s==e)delete r[i];else if(Array.isArray(s)){var o=s.indexOf(e);o!=-1&&(s.splice(o,1),s.length==1&&(r[i]=s[0]))}}},this.bindKey=function(e,t,n){typeof e==\"object\"&&(n==undefined&&(n=e.position),e=e[this.platform]);if(!e)return;if(typeof t==\"function\")return this.addCommand({exec:t,bindKey:e,name:t.name||e});e.split(\"|\").forEach(function(e){var r=\"\";if(e.indexOf(\" \")!=-1){var i=e.split(/\\s+/);e=i.pop(),i.forEach(function(e){var t=this.parseKeys(e),n=s[t.hashId]+t.key;r+=(r?\" \":\"\")+n,this._addCommandToBinding(r,\"chainKeys\")},this),r+=\" \"}var o=this.parseKeys(e),u=s[o.hashId]+o.key;this._addCommandToBinding(r+u,t,n)},this)},this._addCommandToBinding=function(t,n,r){var i=this.commandKeyBinding,s;if(!n)delete i[t];else if(!i[t]||this.$singleCommand)i[t]=n;else{Array.isArray(i[t])?(s=i[t].indexOf(n))!=-1&&i[t].splice(s,1):i[t]=[i[t]],typeof r!=\"number\"&&(r||n.isDefault?r=-100:r=e(n));var o=i[t];for(s=0;s<o.length;s++){var u=o[s],a=e(u);if(a>r)break}o.splice(s,0,n)}},this.addCommands=function(e){e&&Object.keys(e).forEach(function(t){var n=e[t];if(!n)return;if(typeof n==\"string\")return this.bindKey(n,t);typeof n==\"function\"&&(n={exec:n});if(typeof n!=\"object\")return;n.name||(n.name=t),this.addCommand(n)},this)},this.removeCommands=function(e){Object.keys(e).forEach(function(t){this.removeCommand(e[t])},this)},this.bindKeys=function(e){Object.keys(e).forEach(function(t){this.bindKey(t,e[t])},this)},this._buildKeyHash=function(e){this.bindKey(e.bindKey,e)},this.parseKeys=function(e){var t=e.toLowerCase().split(/[\\-\\+]([\\-\\+])?/).filter(function(e){return e}),n=t.pop(),i=r[n];if(r.FUNCTION_KEYS[i])n=r.FUNCTION_KEYS[i].toLowerCase();else{if(!t.length)return{key:n,hashId:-1};if(t.length==1&&t[0]==\"shift\")return{key:n.toUpperCase(),hashId:-1}}var s=0;for(var o=t.length;o--;){var u=r.KEY_MODS[t[o]];if(u==null)return typeof console!=\"undefined\"&&console.error(\"invalid modifier \"+t[o]+\" in \"+e),!1;s|=u}return{key:n,hashId:s}},this.findKeyCommand=function(t,n){var r=s[t]+n;return this.commandKeyBinding[r]},this.handleKeyboard=function(e,t,n,r){var i=s[t]+n,o=this.commandKeyBinding[i];e.$keyChain&&(e.$keyChain+=\" \"+i,o=this.commandKeyBinding[e.$keyChain]||o);if(o)if(o==\"chainKeys\"||o[o.length-1]==\"chainKeys\")return e.$keyChain=e.$keyChain||i,{command:\"null\"};if(e.$keyChain)if(!!t&&t!=4||n.length!=1){if(t==-1||r>0)e.$keyChain=\"\"}else e.$keyChain=e.$keyChain.slice(0,-i.length-1);return{command:o}},this.getStatusText=function(e,t){return t.$keyChain||\"\"}}.call(o.prototype),t.HashHandler=o,t.MultiHashHandler=u}),define(\"ace/commands/command_manager\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/keyboard/hash_handler\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../keyboard/hash_handler\").MultiHashHandler,s=e(\"../lib/event_emitter\").EventEmitter,o=function(e,t){i.call(this,t,e),this.byName=this.commands,this.setDefaultHandler(\"exec\",function(e){return e.command.exec(e.editor,e.args||{})})};r.inherits(o,i),function(){r.implement(this,s),this.exec=function(e,t,n){if(Array.isArray(e)){for(var r=e.length;r--;)if(this.exec(e[r],t,n))return!0;return!1}typeof e==\"string\"&&(e=this.commands[e]);if(!e)return!1;if(t&&t.$readOnly&&!e.readOnly)return!1;var i={editor:t,command:e,args:n};return i.returnValue=this._emit(\"exec\",i),this._signal(\"afterExec\",i),i.returnValue===!1?!1:!0},this.toggleRecording=function(e){if(this.$inReplay)return;return e&&e._emit(\"changeStatus\"),this.recording?(this.macro.pop(),this.removeEventListener(\"exec\",this.$addCommandToMacro),this.macro.length||(this.macro=this.oldMacro),this.recording=!1):(this.$addCommandToMacro||(this.$addCommandToMacro=function(e){this.macro.push([e.command,e.args])}.bind(this)),this.oldMacro=this.macro,this.macro=[],this.on(\"exec\",this.$addCommandToMacro),this.recording=!0)},this.replay=function(e){if(this.$inReplay||!this.macro)return;if(this.recording)return this.toggleRecording(e);try{this.$inReplay=!0,this.macro.forEach(function(t){typeof t==\"string\"?this.exec(t,e):this.exec(t[0],e,t[1])},this)}finally{this.$inReplay=!1}},this.trimMacro=function(e){return e.map(function(e){return typeof e[0]!=\"string\"&&(e[0]=e[0].name),e[1]||(e=e[0]),e})}}.call(o.prototype),t.CommandManager=o}),define(\"ace/commands/default_commands\",[\"require\",\"exports\",\"module\",\"ace/lib/lang\",\"ace/config\",\"ace/range\"],function(e,t,n){\"use strict\";function o(e,t){return{win:e,mac:t}}var r=e(\"../lib/lang\"),i=e(\"../config\"),s=e(\"../range\").Range;t.commands=[{name:\"showSettingsMenu\",bindKey:o(\"Ctrl-,\",\"Command-,\"),exec:function(e){i.loadModule(\"ace/ext/settings_menu\",function(t){t.init(e),e.showSettingsMenu()})},readOnly:!0},{name:\"goToNextError\",bindKey:o(\"Alt-E\",\"Ctrl-E\"),exec:function(e){i.loadModule(\"ace/ext/error_marker\",function(t){t.showErrorMarker(e,1)})},scrollIntoView:\"animate\",readOnly:!0},{name:\"goToPreviousError\",bindKey:o(\"Alt-Shift-E\",\"Ctrl-Shift-E\"),exec:function(e){i.loadModule(\"ace/ext/error_marker\",function(t){t.showErrorMarker(e,-1)})},scrollIntoView:\"animate\",readOnly:!0},{name:\"selectall\",bindKey:o(\"Ctrl-A\",\"Command-A\"),exec:function(e){e.selectAll()},readOnly:!0},{name:\"centerselection\",bindKey:o(null,\"Ctrl-L\"),exec:function(e){e.centerSelection()},readOnly:!0},{name:\"gotoline\",bindKey:o(\"Ctrl-L\",\"Command-L\"),exec:function(e){var t=parseInt(prompt(\"Enter line number:\"),10);isNaN(t)||e.gotoLine(t)},readOnly:!0},{name:\"fold\",bindKey:o(\"Alt-L|Ctrl-F1\",\"Command-Alt-L|Command-F1\"),exec:function(e){e.session.toggleFold(!1)},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"unfold\",bindKey:o(\"Alt-Shift-L|Ctrl-Shift-F1\",\"Command-Alt-Shift-L|Command-Shift-F1\"),exec:function(e){e.session.toggleFold(!0)},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"toggleFoldWidget\",bindKey:o(\"F2\",\"F2\"),exec:function(e){e.session.toggleFoldWidget()},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"toggleParentFoldWidget\",bindKey:o(\"Alt-F2\",\"Alt-F2\"),exec:function(e){e.session.toggleFoldWidget(!0)},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"foldall\",bindKey:o(null,\"Ctrl-Command-Option-0\"),exec:function(e){e.session.foldAll()},scrollIntoView:\"center\",readOnly:!0},{name:\"foldOther\",bindKey:o(\"Alt-0\",\"Command-Option-0\"),exec:function(e){e.session.foldAll(),e.session.unfold(e.selection.getAllRanges())},scrollIntoView:\"center\",readOnly:!0},{name:\"unfoldall\",bindKey:o(\"Alt-Shift-0\",\"Command-Option-Shift-0\"),exec:function(e){e.session.unfold()},scrollIntoView:\"center\",readOnly:!0},{name:\"findnext\",bindKey:o(\"Ctrl-K\",\"Command-G\"),exec:function(e){e.findNext()},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"findprevious\",bindKey:o(\"Ctrl-Shift-K\",\"Command-Shift-G\"),exec:function(e){e.findPrevious()},multiSelectAction:\"forEach\",scrollIntoView:\"center\",readOnly:!0},{name:\"selectOrFindNext\",bindKey:o(\"Alt-K\",\"Ctrl-G\"),exec:function(e){e.selection.isEmpty()?e.selection.selectWord():e.findNext()},readOnly:!0},{name:\"selectOrFindPrevious\",bindKey:o(\"Alt-Shift-K\",\"Ctrl-Shift-G\"),exec:function(e){e.selection.isEmpty()?e.selection.selectWord():e.findPrevious()},readOnly:!0},{name:\"find\",bindKey:o(\"Ctrl-F\",\"Command-F\"),exec:function(e){i.loadModule(\"ace/ext/searchbox\",function(t){t.Search(e)})},readOnly:!0},{name:\"overwrite\",bindKey:\"Insert\",exec:function(e){e.toggleOverwrite()},readOnly:!0},{name:\"selecttostart\",bindKey:o(\"Ctrl-Shift-Home\",\"Command-Shift-Up\"),exec:function(e){e.getSelection().selectFileStart()},multiSelectAction:\"forEach\",readOnly:!0,scrollIntoView:\"animate\",aceCommandGroup:\"fileJump\"},{name:\"gotostart\",bindKey:o(\"Ctrl-Home\",\"Command-Home|Command-Up\"),exec:function(e){e.navigateFileStart()},multiSelectAction:\"forEach\",readOnly:!0,scrollIntoView:\"animate\",aceCommandGroup:\"fileJump\"},{name:\"selectup\",bindKey:o(\"Shift-Up\",\"Shift-Up\"),exec:function(e){e.getSelection().selectUp()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"golineup\",bindKey:o(\"Up\",\"Up|Ctrl-P\"),exec:function(e,t){e.navigateUp(t.times)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selecttoend\",bindKey:o(\"Ctrl-Shift-End\",\"Command-Shift-Down\"),exec:function(e){e.getSelection().selectFileEnd()},multiSelectAction:\"forEach\",readOnly:!0,scrollIntoView:\"animate\",aceCommandGroup:\"fileJump\"},{name:\"gotoend\",bindKey:o(\"Ctrl-End\",\"Command-End|Command-Down\"),exec:function(e){e.navigateFileEnd()},multiSelectAction:\"forEach\",readOnly:!0,scrollIntoView:\"animate\",aceCommandGroup:\"fileJump\"},{name:\"selectdown\",bindKey:o(\"Shift-Down\",\"Shift-Down\"),exec:function(e){e.getSelection().selectDown()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"golinedown\",bindKey:o(\"Down\",\"Down|Ctrl-N\"),exec:function(e,t){e.navigateDown(t.times)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectwordleft\",bindKey:o(\"Ctrl-Shift-Left\",\"Option-Shift-Left\"),exec:function(e){e.getSelection().selectWordLeft()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotowordleft\",bindKey:o(\"Ctrl-Left\",\"Option-Left\"),exec:function(e){e.navigateWordLeft()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selecttolinestart\",bindKey:o(\"Alt-Shift-Left\",\"Command-Shift-Left\"),exec:function(e){e.getSelection().selectLineStart()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotolinestart\",bindKey:o(\"Alt-Left|Home\",\"Command-Left|Home|Ctrl-A\"),exec:function(e){e.navigateLineStart()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectleft\",bindKey:o(\"Shift-Left\",\"Shift-Left\"),exec:function(e){e.getSelection().selectLeft()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotoleft\",bindKey:o(\"Left\",\"Left|Ctrl-B\"),exec:function(e,t){e.navigateLeft(t.times)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectwordright\",bindKey:o(\"Ctrl-Shift-Right\",\"Option-Shift-Right\"),exec:function(e){e.getSelection().selectWordRight()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotowordright\",bindKey:o(\"Ctrl-Right\",\"Option-Right\"),exec:function(e){e.navigateWordRight()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selecttolineend\",bindKey:o(\"Alt-Shift-Right\",\"Command-Shift-Right\"),exec:function(e){e.getSelection().selectLineEnd()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotolineend\",bindKey:o(\"Alt-Right|End\",\"Command-Right|End|Ctrl-E\"),exec:function(e){e.navigateLineEnd()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectright\",bindKey:o(\"Shift-Right\",\"Shift-Right\"),exec:function(e){e.getSelection().selectRight()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"gotoright\",bindKey:o(\"Right\",\"Right|Ctrl-F\"),exec:function(e,t){e.navigateRight(t.times)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectpagedown\",bindKey:\"Shift-PageDown\",exec:function(e){e.selectPageDown()},readOnly:!0},{name:\"pagedown\",bindKey:o(null,\"Option-PageDown\"),exec:function(e){e.scrollPageDown()},readOnly:!0},{name:\"gotopagedown\",bindKey:o(\"PageDown\",\"PageDown|Ctrl-V\"),exec:function(e){e.gotoPageDown()},readOnly:!0},{name:\"selectpageup\",bindKey:\"Shift-PageUp\",exec:function(e){e.selectPageUp()},readOnly:!0},{name:\"pageup\",bindKey:o(null,\"Option-PageUp\"),exec:function(e){e.scrollPageUp()},readOnly:!0},{name:\"gotopageup\",bindKey:\"PageUp\",exec:function(e){e.gotoPageUp()},readOnly:!0},{name:\"scrollup\",bindKey:o(\"Ctrl-Up\",null),exec:function(e){e.renderer.scrollBy(0,-2*e.renderer.layerConfig.lineHeight)},readOnly:!0},{name:\"scrolldown\",bindKey:o(\"Ctrl-Down\",null),exec:function(e){e.renderer.scrollBy(0,2*e.renderer.layerConfig.lineHeight)},readOnly:!0},{name:\"selectlinestart\",bindKey:\"Shift-Home\",exec:function(e){e.getSelection().selectLineStart()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectlineend\",bindKey:\"Shift-End\",exec:function(e){e.getSelection().selectLineEnd()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"togglerecording\",bindKey:o(\"Ctrl-Alt-E\",\"Command-Option-E\"),exec:function(e){e.commands.toggleRecording(e)},readOnly:!0},{name:\"replaymacro\",bindKey:o(\"Ctrl-Shift-E\",\"Command-Shift-E\"),exec:function(e){e.commands.replay(e)},readOnly:!0},{name:\"jumptomatching\",bindKey:o(\"Ctrl-P\",\"Ctrl-P\"),exec:function(e){e.jumpToMatching()},multiSelectAction:\"forEach\",scrollIntoView:\"animate\",readOnly:!0},{name:\"selecttomatching\",bindKey:o(\"Ctrl-Shift-P\",\"Ctrl-Shift-P\"),exec:function(e){e.jumpToMatching(!0)},multiSelectAction:\"forEach\",scrollIntoView:\"animate\",readOnly:!0},{name:\"expandToMatching\",bindKey:o(\"Ctrl-Shift-M\",\"Ctrl-Shift-M\"),exec:function(e){e.jumpToMatching(!0,!0)},multiSelectAction:\"forEach\",scrollIntoView:\"animate\",readOnly:!0},{name:\"passKeysToBrowser\",bindKey:o(null,null),exec:function(){},passEvent:!0,readOnly:!0},{name:\"copy\",exec:function(e){},readOnly:!0},{name:\"cut\",exec:function(e){var t=e.getSelectionRange();e._emit(\"cut\",t),e.selection.isEmpty()||(e.session.remove(t),e.clearSelection())},scrollIntoView:\"cursor\",multiSelectAction:\"forEach\"},{name:\"paste\",exec:function(e,t){e.$handlePaste(t)},scrollIntoView:\"cursor\"},{name:\"removeline\",bindKey:o(\"Ctrl-D\",\"Command-D\"),exec:function(e){e.removeLines()},scrollIntoView:\"cursor\",multiSelectAction:\"forEachLine\"},{name:\"duplicateSelection\",bindKey:o(\"Ctrl-Shift-D\",\"Command-Shift-D\"),exec:function(e){e.duplicateSelection()},scrollIntoView:\"cursor\",multiSelectAction:\"forEach\"},{name:\"sortlines\",bindKey:o(\"Ctrl-Alt-S\",\"Command-Alt-S\"),exec:function(e){e.sortLines()},scrollIntoView:\"selection\",multiSelectAction:\"forEachLine\"},{name:\"togglecomment\",bindKey:o(\"Ctrl-/\",\"Command-/\"),exec:function(e){e.toggleCommentLines()},multiSelectAction:\"forEachLine\",scrollIntoView:\"selectionPart\"},{name:\"toggleBlockComment\",bindKey:o(\"Ctrl-Shift-/\",\"Command-Shift-/\"),exec:function(e){e.toggleBlockComment()},multiSelectAction:\"forEach\",scrollIntoView:\"selectionPart\"},{name:\"modifyNumberUp\",bindKey:o(\"Ctrl-Shift-Up\",\"Alt-Shift-Up\"),exec:function(e){e.modifyNumber(1)},scrollIntoView:\"cursor\",multiSelectAction:\"forEach\"},{name:\"modifyNumberDown\",bindKey:o(\"Ctrl-Shift-Down\",\"Alt-Shift-Down\"),exec:function(e){e.modifyNumber(-1)},scrollIntoView:\"cursor\",multiSelectAction:\"forEach\"},{name:\"replace\",bindKey:o(\"Ctrl-H\",\"Command-Option-F\"),exec:function(e){i.loadModule(\"ace/ext/searchbox\",function(t){t.Search(e,!0)})}},{name:\"undo\",bindKey:o(\"Ctrl-Z\",\"Command-Z\"),exec:function(e){e.undo()}},{name:\"redo\",bindKey:o(\"Ctrl-Shift-Z|Ctrl-Y\",\"Command-Shift-Z|Command-Y\"),exec:function(e){e.redo()}},{name:\"copylinesup\",bindKey:o(\"Alt-Shift-Up\",\"Command-Option-Up\"),exec:function(e){e.copyLinesUp()},scrollIntoView:\"cursor\"},{name:\"movelinesup\",bindKey:o(\"Alt-Up\",\"Option-Up\"),exec:function(e){e.moveLinesUp()},scrollIntoView:\"cursor\"},{name:\"copylinesdown\",bindKey:o(\"Alt-Shift-Down\",\"Command-Option-Down\"),exec:function(e){e.copyLinesDown()},scrollIntoView:\"cursor\"},{name:\"movelinesdown\",bindKey:o(\"Alt-Down\",\"Option-Down\"),exec:function(e){e.moveLinesDown()},scrollIntoView:\"cursor\"},{name:\"del\",bindKey:o(\"Delete\",\"Delete|Ctrl-D|Shift-Delete\"),exec:function(e){e.remove(\"right\")},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"backspace\",bindKey:o(\"Shift-Backspace|Backspace\",\"Ctrl-Backspace|Shift-Backspace|Backspace|Ctrl-H\"),exec:function(e){e.remove(\"left\")},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"cut_or_delete\",bindKey:o(\"Shift-Delete\",null),exec:function(e){if(!e.selection.isEmpty())return!1;e.remove(\"left\")},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"removetolinestart\",bindKey:o(\"Alt-Backspace\",\"Command-Backspace\"),exec:function(e){e.removeToLineStart()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"removetolineend\",bindKey:o(\"Alt-Delete\",\"Ctrl-K\"),exec:function(e){e.removeToLineEnd()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"removewordleft\",bindKey:o(\"Ctrl-Backspace\",\"Alt-Backspace|Ctrl-Alt-Backspace\"),exec:function(e){e.removeWordLeft()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"removewordright\",bindKey:o(\"Ctrl-Delete\",\"Alt-Delete\"),exec:function(e){e.removeWordRight()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"outdent\",bindKey:o(\"Shift-Tab\",\"Shift-Tab\"),exec:function(e){e.blockOutdent()},multiSelectAction:\"forEach\",scrollIntoView:\"selectionPart\"},{name:\"indent\",bindKey:o(\"Tab\",\"Tab\"),exec:function(e){e.indent()},multiSelectAction:\"forEach\",scrollIntoView:\"selectionPart\"},{name:\"blockoutdent\",bindKey:o(\"Ctrl-[\",\"Ctrl-[\"),exec:function(e){e.blockOutdent()},multiSelectAction:\"forEachLine\",scrollIntoView:\"selectionPart\"},{name:\"blockindent\",bindKey:o(\"Ctrl-]\",\"Ctrl-]\"),exec:function(e){e.blockIndent()},multiSelectAction:\"forEachLine\",scrollIntoView:\"selectionPart\"},{name:\"insertstring\",exec:function(e,t){e.insert(t)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"inserttext\",exec:function(e,t){e.insert(r.stringRepeat(t.text||\"\",t.times||1))},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"splitline\",bindKey:o(null,\"Ctrl-O\"),exec:function(e){e.splitLine()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"transposeletters\",bindKey:o(\"Ctrl-T\",\"Ctrl-T\"),exec:function(e){e.transposeLetters()},multiSelectAction:function(e){e.transposeSelections(1)},scrollIntoView:\"cursor\"},{name:\"touppercase\",bindKey:o(\"Ctrl-U\",\"Ctrl-U\"),exec:function(e){e.toUpperCase()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"tolowercase\",bindKey:o(\"Ctrl-Shift-U\",\"Ctrl-Shift-U\"),exec:function(e){e.toLowerCase()},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\"},{name:\"expandtoline\",bindKey:o(\"Ctrl-Shift-L\",\"Command-Shift-L\"),exec:function(e){var t=e.selection.getRange();t.start.column=t.end.column=0,t.end.row++,e.selection.setRange(t,!1)},multiSelectAction:\"forEach\",scrollIntoView:\"cursor\",readOnly:!0},{name:\"joinlines\",bindKey:o(null,null),exec:function(e){var t=e.selection.isBackwards(),n=t?e.selection.getSelectionLead():e.selection.getSelectionAnchor(),i=t?e.selection.getSelectionAnchor():e.selection.getSelectionLead(),o=e.session.doc.getLine(n.row).length,u=e.session.doc.getTextRange(e.selection.getRange()),a=u.replace(/\\n\\s*/,\" \").length,f=e.session.doc.getLine(n.row);for(var l=n.row+1;l<=i.row+1;l++){var c=r.stringTrimLeft(r.stringTrimRight(e.session.doc.getLine(l)));c.length!==0&&(c=\" \"+c),f+=c}i.row+1<e.session.doc.getLength()-1&&(f+=e.session.doc.getNewLineCharacter()),e.clearSelection(),e.session.doc.replace(new s(n.row,0,i.row+2,0),f),a>0?(e.selection.moveCursorTo(n.row,n.column),e.selection.selectTo(n.row,n.column+a)):(o=e.session.doc.getLine(n.row).length>o?o+1:o,e.selection.moveCursorTo(n.row,o))},multiSelectAction:\"forEach\",readOnly:!0},{name:\"invertSelection\",bindKey:o(null,null),exec:function(e){var t=e.session.doc.getLength()-1,n=e.session.doc.getLine(t).length,r=e.selection.rangeList.ranges,i=[];r.length<1&&(r=[e.selection.getRange()]);for(var o=0;o<r.length;o++)o==r.length-1&&(r[o].end.row!==t||r[o].end.column!==n)&&i.push(new s(r[o].end.row,r[o].end.column,t,n)),o===0?(r[o].start.row!==0||r[o].start.column!==0)&&i.push(new s(0,0,r[o].start.row,r[o].start.column)):i.push(new s(r[o-1].end.row,r[o-1].end.column,r[o].start.row,r[o].start.column));e.exitMultiSelectMode(),e.clearSelection();for(var o=0;o<i.length;o++)e.selection.addRange(i[o],!1)},readOnly:!0,scrollIntoView:\"none\"}]}),define(\"ace/editor\",[\"require\",\"exports\",\"module\",\"ace/lib/fixoldbrowsers\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/lib/lang\",\"ace/lib/useragent\",\"ace/keyboard/textinput\",\"ace/mouse/mouse_handler\",\"ace/mouse/fold_handler\",\"ace/keyboard/keybinding\",\"ace/edit_session\",\"ace/search\",\"ace/range\",\"ace/lib/event_emitter\",\"ace/commands/command_manager\",\"ace/commands/default_commands\",\"ace/config\",\"ace/token_iterator\"],function(e,t,n){\"use strict\";e(\"./lib/fixoldbrowsers\");var r=e(\"./lib/oop\"),i=e(\"./lib/dom\"),s=e(\"./lib/lang\"),o=e(\"./lib/useragent\"),u=e(\"./keyboard/textinput\").TextInput,a=e(\"./mouse/mouse_handler\").MouseHandler,f=e(\"./mouse/fold_handler\").FoldHandler,l=e(\"./keyboard/keybinding\").KeyBinding,c=e(\"./edit_session\").EditSession,h=e(\"./search\").Search,p=e(\"./range\").Range,d=e(\"./lib/event_emitter\").EventEmitter,v=e(\"./commands/command_manager\").CommandManager,m=e(\"./commands/default_commands\").commands,g=e(\"./config\"),y=e(\"./token_iterator\").TokenIterator,b=function(e,t){var n=e.getContainerElement();this.container=n,this.renderer=e,this.commands=new v(o.isMac?\"mac\":\"win\",m),this.textInput=new u(e.getTextAreaContainer(),this),this.renderer.textarea=this.textInput.getElement(),this.keyBinding=new l(this),this.$mouseHandler=new a(this),new f(this),this.$blockScrolling=0,this.$search=(new h).set({wrap:!0}),this.$historyTracker=this.$historyTracker.bind(this),this.commands.on(\"exec\",this.$historyTracker),this.$initOperationListeners(),this._$emitInputEvent=s.delayedCall(function(){this._signal(\"input\",{}),this.session&&this.session.bgTokenizer&&this.session.bgTokenizer.scheduleStart()}.bind(this)),this.on(\"change\",function(e,t){t._$emitInputEvent.schedule(31)}),this.setSession(t||new c(\"\")),g.resetOptions(this),g._signal(\"editor\",this)};(function(){r.implement(this,d),this.$initOperationListeners=function(){function e(e){return e[e.length-1]}this.selections=[],this.commands.on(\"exec\",this.startOperation.bind(this),!0),this.commands.on(\"afterExec\",this.endOperation.bind(this),!0),this.$opResetTimer=s.delayedCall(this.endOperation.bind(this)),this.on(\"change\",function(){this.curOp||this.startOperation(),this.curOp.docChanged=!0}.bind(this),!0),this.on(\"changeSelection\",function(){this.curOp||this.startOperation(),this.curOp.selectionChanged=!0}.bind(this),!0)},this.curOp=null,this.prevOp={},this.startOperation=function(e){if(this.curOp){if(!e||this.curOp.command)return;this.prevOp=this.curOp}e||(this.previousCommand=null,e={}),this.$opResetTimer.schedule(),this.curOp={command:e.command||{},args:e.args,scrollTop:this.renderer.scrollTop},this.curOp.command.name&&this.curOp.command.scrollIntoView!==undefined&&this.$blockScrolling++},this.endOperation=function(e){if(this.curOp){if(e&&e.returnValue===!1)return this.curOp=null;this._signal(\"beforeEndOperation\");var t=this.curOp.command;t.name&&this.$blockScrolling>0&&this.$blockScrolling--;var n=t&&t.scrollIntoView;if(n){switch(n){case\"center-animate\":n=\"animate\";case\"center\":this.renderer.scrollCursorIntoView(null,.5);break;case\"animate\":case\"cursor\":this.renderer.scrollCursorIntoView();break;case\"selectionPart\":var r=this.selection.getRange(),i=this.renderer.layerConfig;(r.start.row>=i.lastRow||r.end.row<=i.firstRow)&&this.renderer.scrollSelectionIntoView(this.selection.anchor,this.selection.lead);break;default:}n==\"animate\"&&this.renderer.animateScrolling(this.curOp.scrollTop)}this.prevOp=this.curOp,this.curOp=null}},this.$mergeableCommands=[\"backspace\",\"del\",\"insertstring\"],this.$historyTracker=function(e){if(!this.$mergeUndoDeltas)return;var t=this.prevOp,n=this.$mergeableCommands,r=t.command&&e.command.name==t.command.name;if(e.command.name==\"insertstring\"){var i=e.args;this.mergeNextCommand===undefined&&(this.mergeNextCommand=!0),r=r&&this.mergeNextCommand&&(!/\\s/.test(i)||/\\s/.test(t.args)),this.mergeNextCommand=!0}else r=r&&n.indexOf(e.command.name)!==-1;this.$mergeUndoDeltas!=\"always\"&&Date.now()-this.sequenceStartTime>2e3&&(r=!1),r?this.session.mergeUndoDeltas=!0:n.indexOf(e.command.name)!==-1&&(this.sequenceStartTime=Date.now())},this.setKeyboardHandler=function(e,t){if(e&&typeof e==\"string\"){this.$keybindingId=e;var n=this;g.loadModule([\"keybinding\",e],function(r){n.$keybindingId==e&&n.keyBinding.setKeyboardHandler(r&&r.handler),t&&t()})}else this.$keybindingId=null,this.keyBinding.setKeyboardHandler(e),t&&t()},this.getKeyboardHandler=function(){return this.keyBinding.getKeyboardHandler()},this.setSession=function(e){if(this.session==e)return;this.curOp&&this.endOperation(),this.curOp={};var t=this.session;if(t){this.session.removeEventListener(\"change\",this.$onDocumentChange),this.session.removeEventListener(\"changeMode\",this.$onChangeMode),this.session.removeEventListener(\"tokenizerUpdate\",this.$onTokenizerUpdate),this.session.removeEventListener(\"changeTabSize\",this.$onChangeTabSize),this.session.removeEventListener(\"changeWrapLimit\",this.$onChangeWrapLimit),this.session.removeEventListener(\"changeWrapMode\",this.$onChangeWrapMode),this.session.removeEventListener(\"onChangeFold\",this.$onChangeFold),this.session.removeEventListener(\"changeFrontMarker\",this.$onChangeFrontMarker),this.session.removeEventListener(\"changeBackMarker\",this.$onChangeBackMarker),this.session.removeEventListener(\"changeBreakpoint\",this.$onChangeBreakpoint),this.session.removeEventListener(\"changeAnnotation\",this.$onChangeAnnotation),this.session.removeEventListener(\"changeOverwrite\",this.$onCursorChange),this.session.removeEventListener(\"changeScrollTop\",this.$onScrollTopChange),this.session.removeEventListener(\"changeScrollLeft\",this.$onScrollLeftChange);var n=this.session.getSelection();n.removeEventListener(\"changeCursor\",this.$onCursorChange),n.removeEventListener(\"changeSelection\",this.$onSelectionChange)}this.session=e,e?(this.$onDocumentChange=this.onDocumentChange.bind(this),e.addEventListener(\"change\",this.$onDocumentChange),this.renderer.setSession(e),this.$onChangeMode=this.onChangeMode.bind(this),e.addEventListener(\"changeMode\",this.$onChangeMode),this.$onTokenizerUpdate=this.onTokenizerUpdate.bind(this),e.addEventListener(\"tokenizerUpdate\",this.$onTokenizerUpdate),this.$onChangeTabSize=this.renderer.onChangeTabSize.bind(this.renderer),e.addEventListener(\"changeTabSize\",this.$onChangeTabSize),this.$onChangeWrapLimit=this.onChangeWrapLimit.bind(this),e.addEventListener(\"changeWrapLimit\",this.$onChangeWrapLimit),this.$onChangeWrapMode=this.onChangeWrapMode.bind(this),e.addEventListener(\"changeWrapMode\",this.$onChangeWrapMode),this.$onChangeFold=this.onChangeFold.bind(this),e.addEventListener(\"changeFold\",this.$onChangeFold),this.$onChangeFrontMarker=this.onChangeFrontMarker.bind(this),this.session.addEventListener(\"changeFrontMarker\",this.$onChangeFrontMarker),this.$onChangeBackMarker=this.onChangeBackMarker.bind(this),this.session.addEventListener(\"changeBackMarker\",this.$onChangeBackMarker),this.$onChangeBreakpoint=this.onChangeBreakpoint.bind(this),this.session.addEventListener(\"changeBreakpoint\",this.$onChangeBreakpoint),this.$onChangeAnnotation=this.onChangeAnnotation.bind(this),this.session.addEventListener(\"changeAnnotation\",this.$onChangeAnnotation),this.$onCursorChange=this.onCursorChange.bind(this),this.session.addEventListener(\"changeOverwrite\",this.$onCursorChange),this.$onScrollTopChange=this.onScrollTopChange.bind(this),this.session.addEventListener(\"changeScrollTop\",this.$onScrollTopChange),this.$onScrollLeftChange=this.onScrollLeftChange.bind(this),this.session.addEventListener(\"changeScrollLeft\",this.$onScrollLeftChange),this.selection=e.getSelection(),this.selection.addEventListener(\"changeCursor\",this.$onCursorChange),this.$onSelectionChange=this.onSelectionChange.bind(this),this.selection.addEventListener(\"changeSelection\",this.$onSelectionChange),this.onChangeMode(),this.$blockScrolling+=1,this.onCursorChange(),this.$blockScrolling-=1,this.onScrollTopChange(),this.onScrollLeftChange(),this.onSelectionChange(),this.onChangeFrontMarker(),this.onChangeBackMarker(),this.onChangeBreakpoint(),this.onChangeAnnotation(),this.session.getUseWrapMode()&&this.renderer.adjustWrapLimit(),this.renderer.updateFull()):(this.selection=null,this.renderer.setSession(e)),this._signal(\"changeSession\",{session:e,oldSession:t}),this.curOp=null,t&&t._signal(\"changeEditor\",{oldEditor:this}),e&&e._signal(\"changeEditor\",{editor:this})},this.getSession=function(){return this.session},this.setValue=function(e,t){return this.session.doc.setValue(e),t?t==1?this.navigateFileEnd():t==-1&&this.navigateFileStart():this.selectAll(),e},this.getValue=function(){return this.session.getValue()},this.getSelection=function(){return this.selection},this.resize=function(e){this.renderer.onResize(e)},this.setTheme=function(e,t){this.renderer.setTheme(e,t)},this.getTheme=function(){return this.renderer.getTheme()},this.setStyle=function(e){this.renderer.setStyle(e)},this.unsetStyle=function(e){this.renderer.unsetStyle(e)},this.getFontSize=function(){return this.getOption(\"fontSize\")||i.computedStyle(this.container,\"fontSize\")},this.setFontSize=function(e){this.setOption(\"fontSize\",e)},this.$highlightBrackets=function(){this.session.$bracketHighlight&&(this.session.removeMarker(this.session.$bracketHighlight),this.session.$bracketHighlight=null);if(this.$highlightPending)return;var e=this;this.$highlightPending=!0,setTimeout(function(){e.$highlightPending=!1;var t=e.session;if(!t||!t.bgTokenizer)return;var n=t.findMatchingBracket(e.getCursorPosition());if(n)var r=new p(n.row,n.column,n.row,n.column+1);else if(t.$mode.getMatching)var r=t.$mode.getMatching(e.session);r&&(t.$bracketHighlight=t.addMarker(r,\"ace_bracket\",\"text\"))},50)},this.$highlightTags=function(){if(this.$highlightTagPending)return;var e=this;this.$highlightTagPending=!0,setTimeout(function(){e.$highlightTagPending=!1;var t=e.session;if(!t||!t.bgTokenizer)return;var n=e.getCursorPosition(),r=new y(e.session,n.row,n.column),i=r.getCurrentToken();if(!i||!/\\b(?:tag-open|tag-name)/.test(i.type)){t.removeMarker(t.$tagHighlight),t.$tagHighlight=null;return}if(i.type.indexOf(\"tag-open\")!=-1){i=r.stepForward();if(!i)return}var s=i.value,o=0,u=r.stepBackward();if(u.value==\"<\"){do u=i,i=r.stepForward(),i&&i.value===s&&i.type.indexOf(\"tag-name\")!==-1&&(u.value===\"<\"?o++:u.value===\"</\"&&o--);while(i&&o>=0)}else{do i=u,u=r.stepBackward(),i&&i.value===s&&i.type.indexOf(\"tag-name\")!==-1&&(u.value===\"<\"?o++:u.value===\"</\"&&o--);while(u&&o<=0);r.stepForward()}if(!i){t.removeMarker(t.$tagHighlight),t.$tagHighlight=null;return}var a=r.getCurrentTokenRow(),f=r.getCurrentTokenColumn(),l=new p(a,f,a,f+i.value.length);t.$tagHighlight&&l.compareRange(t.$backMarkers[t.$tagHighlight].range)!==0&&(t.removeMarker(t.$tagHighlight),t.$tagHighlight=null),l&&!t.$tagHighlight&&(t.$tagHighlight=t.addMarker(l,\"ace_bracket\",\"text\"))},50)},this.focus=function(){var e=this;setTimeout(function(){e.textInput.focus()}),this.textInput.focus()},this.isFocused=function(){return this.textInput.isFocused()},this.blur=function(){this.textInput.blur()},this.onFocus=function(e){if(this.$isFocused)return;this.$isFocused=!0,this.renderer.showCursor(),this.renderer.visualizeFocus(),this._emit(\"focus\",e)},this.onBlur=function(e){if(!this.$isFocused)return;this.$isFocused=!1,this.renderer.hideCursor(),this.renderer.visualizeBlur(),this._emit(\"blur\",e)},this.$cursorChange=function(){this.renderer.updateCursor()},this.onDocumentChange=function(e){var t=this.session.$useWrapMode,n=e.start.row==e.end.row?e.end.row:Infinity;this.renderer.updateLines(e.start.row,n,t),this._signal(\"change\",e),this.$cursorChange(),this.$updateHighlightActiveLine()},this.onTokenizerUpdate=function(e){var t=e.data;this.renderer.updateLines(t.first,t.last)},this.onScrollTopChange=function(){this.renderer.scrollToY(this.session.getScrollTop())},this.onScrollLeftChange=function(){this.renderer.scrollToX(this.session.getScrollLeft())},this.onCursorChange=function(){this.$cursorChange(),this.$blockScrolling||(g.warn(\"Automatically scrolling cursor into view after selection change\",\"this will be disabled in the next version\",\"set editor.$blockScrolling = Infinity to disable this message\"),this.renderer.scrollCursorIntoView()),this.$highlightBrackets(),this.$highlightTags(),this.$updateHighlightActiveLine(),this._signal(\"changeSelection\")},this.$updateHighlightActiveLine=function(){var e=this.getSession(),t;if(this.$highlightActiveLine){if(this.$selectionStyle!=\"line\"||!this.selection.isMultiLine())t=this.getCursorPosition();this.renderer.$maxLines&&this.session.getLength()===1&&!(this.renderer.$minLines>1)&&(t=!1)}if(e.$highlightLineMarker&&!t)e.removeMarker(e.$highlightLineMarker.id),e.$highlightLineMarker=null;else if(!e.$highlightLineMarker&&t){var n=new p(t.row,t.column,t.row,Infinity);n.id=e.addMarker(n,\"ace_active-line\",\"screenLine\"),e.$highlightLineMarker=n}else t&&(e.$highlightLineMarker.start.row=t.row,e.$highlightLineMarker.end.row=t.row,e.$highlightLineMarker.start.column=t.column,e._signal(\"changeBackMarker\"))},this.onSelectionChange=function(e){var t=this.session;t.$selectionMarker&&t.removeMarker(t.$selectionMarker),t.$selectionMarker=null;if(!this.selection.isEmpty()){var n=this.selection.getRange(),r=this.getSelectionStyle();t.$selectionMarker=t.addMarker(n,\"ace_selection\",r)}else this.$updateHighlightActiveLine();var i=this.$highlightSelectedWord&&this.$getSelectionHighLightRegexp();this.session.highlight(i),this._signal(\"changeSelection\")},this.$getSelectionHighLightRegexp=function(){var e=this.session,t=this.getSelectionRange();if(t.isEmpty()||t.isMultiLine())return;var n=t.start.column-1,r=t.end.column+1,i=e.getLine(t.start.row),s=i.length,o=i.substring(Math.max(n,0),Math.min(r,s));if(n>=0&&/^[\\w\\d]/.test(o)||r<=s&&/[\\w\\d]$/.test(o))return;o=i.substring(t.start.column,t.end.column);if(!/^[\\w\\d]+$/.test(o))return;var u=this.$search.$assembleRegExp({wholeWord:!0,caseSensitive:!0,needle:o});return u},this.onChangeFrontMarker=function(){this.renderer.updateFrontMarkers()},this.onChangeBackMarker=function(){this.renderer.updateBackMarkers()},this.onChangeBreakpoint=function(){this.renderer.updateBreakpoints()},this.onChangeAnnotation=function(){this.renderer.setAnnotations(this.session.getAnnotations())},this.onChangeMode=function(e){this.renderer.updateText(),this._emit(\"changeMode\",e)},this.onChangeWrapLimit=function(){this.renderer.updateFull()},this.onChangeWrapMode=function(){this.renderer.onResize(!0)},this.onChangeFold=function(){this.$updateHighlightActiveLine(),this.renderer.updateFull()},this.getSelectedText=function(){return this.session.getTextRange(this.getSelectionRange())},this.getCopyText=function(){var e=this.getSelectedText();return this._signal(\"copy\",e),e},this.onCopy=function(){this.commands.exec(\"copy\",this)},this.onCut=function(){this.commands.exec(\"cut\",this)},this.onPaste=function(e,t){var n={text:e,event:t};this.commands.exec(\"paste\",this,n)},this.$handlePaste=function(e){typeof e==\"string\"&&(e={text:e}),this._signal(\"paste\",e);var t=e.text;if(!this.inMultiSelectMode||this.inVirtualSelectionMode)this.insert(t);else{var n=t.split(/\\r\\n|\\r|\\n/),r=this.selection.rangeList.ranges;if(n.length>r.length||n.length<2||!n[1])return this.commands.exec(\"insertstring\",this,t);for(var i=r.length;i--;){var s=r[i];s.isEmpty()||this.session.remove(s),this.session.insert(s.start,n[i])}}},this.execCommand=function(e,t){return this.commands.exec(e,this,t)},this.insert=function(e,t){var n=this.session,r=n.getMode(),i=this.getCursorPosition();if(this.getBehavioursEnabled()&&!t){var s=r.transformAction(n.getState(i.row),\"insertion\",this,n,e);s&&(e!==s.text&&(this.session.mergeUndoDeltas=!1,this.$mergeNextCommand=!1),e=s.text)}e==\"\t\"&&(e=this.session.getTabString());if(!this.selection.isEmpty()){var o=this.getSelectionRange();i=this.session.remove(o),this.clearSelection()}else if(this.session.getOverwrite()){var o=new p.fromPoints(i,i);o.end.column+=e.length,this.session.remove(o)}if(e==\"\\n\"||e==\"\\r\\n\"){var u=n.getLine(i.row);if(i.column>u.search(/\\S|$/)){var a=u.substr(i.column).search(/\\S|$/);n.doc.removeInLine(i.row,i.column,i.column+a)}}this.clearSelection();var f=i.column,l=n.getState(i.row),u=n.getLine(i.row),c=r.checkOutdent(l,u,e),h=n.insert(i,e);s&&s.selection&&(s.selection.length==2?this.selection.setSelectionRange(new p(i.row,f+s.selection[0],i.row,f+s.selection[1])):this.selection.setSelectionRange(new p(i.row+s.selection[0],s.selection[1],i.row+s.selection[2],s.selection[3])));if(n.getDocument().isNewLine(e)){var d=r.getNextLineIndent(l,u.slice(0,i.column),n.getTabString());n.insert({row:i.row+1,column:0},d)}c&&r.autoOutdent(l,n,i.row)},this.onTextInput=function(e){this.keyBinding.onTextInput(e)},this.onCommandKey=function(e,t,n){this.keyBinding.onCommandKey(e,t,n)},this.setOverwrite=function(e){this.session.setOverwrite(e)},this.getOverwrite=function(){return this.session.getOverwrite()},this.toggleOverwrite=function(){this.session.toggleOverwrite()},this.setScrollSpeed=function(e){this.setOption(\"scrollSpeed\",e)},this.getScrollSpeed=function(){return this.getOption(\"scrollSpeed\")},this.setDragDelay=function(e){this.setOption(\"dragDelay\",e)},this.getDragDelay=function(){return this.getOption(\"dragDelay\")},this.setSelectionStyle=function(e){this.setOption(\"selectionStyle\",e)},this.getSelectionStyle=function(){return this.getOption(\"selectionStyle\")},this.setHighlightActiveLine=function(e){this.setOption(\"highlightActiveLine\",e)},this.getHighlightActiveLine=function(){return this.getOption(\"highlightActiveLine\")},this.setHighlightGutterLine=function(e){this.setOption(\"highlightGutterLine\",e)},this.getHighlightGutterLine=function(){return this.getOption(\"highlightGutterLine\")},this.setHighlightSelectedWord=function(e){this.setOption(\"highlightSelectedWord\",e)},this.getHighlightSelectedWord=function(){return this.$highlightSelectedWord},this.setAnimatedScroll=function(e){this.renderer.setAnimatedScroll(e)},this.getAnimatedScroll=function(){return this.renderer.getAnimatedScroll()},this.setShowInvisibles=function(e){this.renderer.setShowInvisibles(e)},this.getShowInvisibles=function(){return this.renderer.getShowInvisibles()},this.setDisplayIndentGuides=function(e){this.renderer.setDisplayIndentGuides(e)},this.getDisplayIndentGuides=function(){return this.renderer.getDisplayIndentGuides()},this.setShowPrintMargin=function(e){this.renderer.setShowPrintMargin(e)},this.getShowPrintMargin=function(){return this.renderer.getShowPrintMargin()},this.setPrintMarginColumn=function(e){this.renderer.setPrintMarginColumn(e)},this.getPrintMarginColumn=function(){return this.renderer.getPrintMarginColumn()},this.setReadOnly=function(e){this.setOption(\"readOnly\",e)},this.getReadOnly=function(){return this.getOption(\"readOnly\")},this.setBehavioursEnabled=function(e){this.setOption(\"behavioursEnabled\",e)},this.getBehavioursEnabled=function(){return this.getOption(\"behavioursEnabled\")},this.setWrapBehavioursEnabled=function(e){this.setOption(\"wrapBehavioursEnabled\",e)},this.getWrapBehavioursEnabled=function(){return this.getOption(\"wrapBehavioursEnabled\")},this.setShowFoldWidgets=function(e){this.setOption(\"showFoldWidgets\",e)},this.getShowFoldWidgets=function(){return this.getOption(\"showFoldWidgets\")},this.setFadeFoldWidgets=function(e){this.setOption(\"fadeFoldWidgets\",e)},this.getFadeFoldWidgets=function(){return this.getOption(\"fadeFoldWidgets\")},this.remove=function(e){this.selection.isEmpty()&&(e==\"left\"?this.selection.selectLeft():this.selection.selectRight());var t=this.getSelectionRange();if(this.getBehavioursEnabled()){var n=this.session,r=n.getState(t.start.row),i=n.getMode().transformAction(r,\"deletion\",this,n,t);if(t.end.column===0){var s=n.getTextRange(t);if(s[s.length-1]==\"\\n\"){var o=n.getLine(t.end.row);/^\\s+$/.test(o)&&(t.end.column=o.length)}}i&&(t=i)}this.session.remove(t),this.clearSelection()},this.removeWordRight=function(){this.selection.isEmpty()&&this.selection.selectWordRight(),this.session.remove(this.getSelectionRange()),this.clearSelection()},this.removeWordLeft=function(){this.selection.isEmpty()&&this.selection.selectWordLeft(),this.session.remove(this.getSelectionRange()),this.clearSelection()},this.removeToLineStart=function(){this.selection.isEmpty()&&this.selection.selectLineStart(),this.session.remove(this.getSelectionRange()),this.clearSelection()},this.removeToLineEnd=function(){this.selection.isEmpty()&&this.selection.selectLineEnd();var e=this.getSelectionRange();e.start.column==e.end.column&&e.start.row==e.end.row&&(e.end.column=0,e.end.row++),this.session.remove(e),this.clearSelection()},this.splitLine=function(){this.selection.isEmpty()||(this.session.remove(this.getSelectionRange()),this.clearSelection());var e=this.getCursorPosition();this.insert(\"\\n\"),this.moveCursorToPosition(e)},this.transposeLetters=function(){if(!this.selection.isEmpty())return;var e=this.getCursorPosition(),t=e.column;if(t===0)return;var n=this.session.getLine(e.row),r,i;t<n.length?(r=n.charAt(t)+n.charAt(t-1),i=new p(e.row,t-1,e.row,t+1)):(r=n.charAt(t-1)+n.charAt(t-2),i=new p(e.row,t-2,e.row,t)),this.session.replace(i,r)},this.toLowerCase=function(){var e=this.getSelectionRange();this.selection.isEmpty()&&this.selection.selectWord();var t=this.getSelectionRange(),n=this.session.getTextRange(t);this.session.replace(t,n.toLowerCase()),this.selection.setSelectionRange(e)},this.toUpperCase=function(){var e=this.getSelectionRange();this.selection.isEmpty()&&this.selection.selectWord();var t=this.getSelectionRange(),n=this.session.getTextRange(t);this.session.replace(t,n.toUpperCase()),this.selection.setSelectionRange(e)},this.indent=function(){var e=this.session,t=this.getSelectionRange();if(t.start.row<t.end.row){var n=this.$getSelectedRows();e.indentRows(n.first,n.last,\"\t\");return}if(t.start.column<t.end.column){var r=e.getTextRange(t);if(!/^\\s+$/.test(r)){var n=this.$getSelectedRows();e.indentRows(n.first,n.last,\"\t\");return}}var i=e.getLine(t.start.row),o=t.start,u=e.getTabSize(),a=e.documentToScreenColumn(o.row,o.column);if(this.session.getUseSoftTabs())var f=u-a%u,l=s.stringRepeat(\" \",f);else{var f=a%u;while(i[t.start.column]==\" \"&&f)t.start.column--,f--;this.selection.setSelectionRange(t),l=\"\t\"}return this.insert(l)},this.blockIndent=function(){var e=this.$getSelectedRows();this.session.indentRows(e.first,e.last,\"\t\")},this.blockOutdent=function(){var e=this.session.getSelection();this.session.outdentRows(e.getRange())},this.sortLines=function(){var e=this.$getSelectedRows(),t=this.session,n=[];for(i=e.first;i<=e.last;i++)n.push(t.getLine(i));n.sort(function(e,t){return e.toLowerCase()<t.toLowerCase()?-1:e.toLowerCase()>t.toLowerCase()?1:0});var r=new p(0,0,0,0);for(var i=e.first;i<=e.last;i++){var s=t.getLine(i);r.start.row=i,r.end.row=i,r.end.column=s.length,t.replace(r,n[i-e.first])}},this.toggleCommentLines=function(){var e=this.session.getState(this.getCursorPosition().row),t=this.$getSelectedRows();this.session.getMode().toggleCommentLines(e,this.session,t.first,t.last)},this.toggleBlockComment=function(){var e=this.getCursorPosition(),t=this.session.getState(e.row),n=this.getSelectionRange();this.session.getMode().toggleBlockComment(t,this.session,n,e)},this.getNumberAt=function(e,t){var n=/[\\-]?[0-9]+(?:\\.[0-9]+)?/g;n.lastIndex=0;var r=this.session.getLine(e);while(n.lastIndex<t){var i=n.exec(r);if(i.index<=t&&i.index+i[0].length>=t){var s={value:i[0],start:i.index,end:i.index+i[0].length};return s}}return null},this.modifyNumber=function(e){var t=this.selection.getCursor().row,n=this.selection.getCursor().column,r=new p(t,n-1,t,n),i=this.session.getTextRange(r);if(!isNaN(parseFloat(i))&&isFinite(i)){var s=this.getNumberAt(t,n);if(s){var o=s.value.indexOf(\".\")>=0?s.start+s.value.indexOf(\".\")+1:s.end,u=s.start+s.value.length-o,a=parseFloat(s.value);a*=Math.pow(10,u),o!==s.end&&n<o?e*=Math.pow(10,s.end-n-1):e*=Math.pow(10,s.end-n),a+=e,a/=Math.pow(10,u);var f=a.toFixed(u),l=new p(t,s.start,t,s.end);this.session.replace(l,f),this.moveCursorTo(t,Math.max(s.start+1,n+f.length-s.value.length))}}},this.removeLines=function(){var e=this.$getSelectedRows();this.session.removeFullLines(e.first,e.last),this.clearSelection()},this.duplicateSelection=function(){var e=this.selection,t=this.session,n=e.getRange(),r=e.isBackwards();if(n.isEmpty()){var i=n.start.row;t.duplicateLines(i,i)}else{var s=r?n.start:n.end,o=t.insert(s,t.getTextRange(n),!1);n.start=s,n.end=o,e.setSelectionRange(n,r)}},this.moveLinesDown=function(){this.$moveLines(1,!1)},this.moveLinesUp=function(){this.$moveLines(-1,!1)},this.moveText=function(e,t,n){return this.session.moveText(e,t,n)},this.copyLinesUp=function(){this.$moveLines(-1,!0)},this.copyLinesDown=function(){this.$moveLines(1,!0)},this.$moveLines=function(e,t){var n,r,i=this.selection;if(!i.inMultiSelectMode||this.inVirtualSelectionMode){var s=i.toOrientedRange();n=this.$getSelectedRows(s),r=this.session.$moveLines(n.first,n.last,t?0:e),t&&e==-1&&(r=0),s.moveBy(r,0),i.fromOrientedRange(s)}else{var o=i.rangeList.ranges;i.rangeList.detach(this.session),this.inVirtualSelectionMode=!0;var u=0,a=0,f=o.length;for(var l=0;l<f;l++){var c=l;o[l].moveBy(u,0),n=this.$getSelectedRows(o[l]);var h=n.first,p=n.last;while(++l<f){a&&o[l].moveBy(a,0);var d=this.$getSelectedRows(o[l]);if(t&&d.first!=p)break;if(!t&&d.first>p+1)break;p=d.last}l--,u=this.session.$moveLines(h,p,t?0:e),t&&e==-1&&(c=l+1);while(c<=l)o[c].moveBy(u,0),c++;t||(u=0),a+=u}i.fromOrientedRange(i.ranges[0]),i.rangeList.attach(this.session),this.inVirtualSelectionMode=!1}},this.$getSelectedRows=function(e){return e=(e||this.getSelectionRange()).collapseRows(),{first:this.session.getRowFoldStart(e.start.row),last:this.session.getRowFoldEnd(e.end.row)}},this.onCompositionStart=function(e){this.renderer.showComposition(this.getCursorPosition())},this.onCompositionUpdate=function(e){this.renderer.setCompositionText(e)},this.onCompositionEnd=function(){this.renderer.hideComposition()},this.getFirstVisibleRow=function(){return this.renderer.getFirstVisibleRow()},this.getLastVisibleRow=function(){return this.renderer.getLastVisibleRow()},this.isRowVisible=function(e){return e>=this.getFirstVisibleRow()&&e<=this.getLastVisibleRow()},this.isRowFullyVisible=function(e){return e>=this.renderer.getFirstFullyVisibleRow()&&e<=this.renderer.getLastFullyVisibleRow()},this.$getVisibleRowCount=function(){return this.renderer.getScrollBottomRow()-this.renderer.getScrollTopRow()+1},this.$moveByPage=function(e,t){var n=this.renderer,r=this.renderer.layerConfig,i=e*Math.floor(r.height/r.lineHeight);this.$blockScrolling++,t===!0?this.selection.$moveSelection(function(){this.moveCursorBy(i,0)}):t===!1&&(this.selection.moveCursorBy(i,0),this.selection.clearSelection()),this.$blockScrolling--;var s=n.scrollTop;n.scrollBy(0,i*r.lineHeight),t!=null&&n.scrollCursorIntoView(null,.5),n.animateScrolling(s)},this.selectPageDown=function(){this.$moveByPage(1,!0)},this.selectPageUp=function(){this.$moveByPage(-1,!0)},this.gotoPageDown=function(){this.$moveByPage(1,!1)},this.gotoPageUp=function(){this.$moveByPage(-1,!1)},this.scrollPageDown=function(){this.$moveByPage(1)},this.scrollPageUp=function(){this.$moveByPage(-1)},this.scrollToRow=function(e){this.renderer.scrollToRow(e)},this.scrollToLine=function(e,t,n,r){this.renderer.scrollToLine(e,t,n,r)},this.centerSelection=function(){var e=this.getSelectionRange(),t={row:Math.floor(e.start.row+(e.end.row-e.start.row)/2),column:Math.floor(e.start.column+(e.end.column-e.start.column)/2)};this.renderer.alignCursor(t,.5)},this.getCursorPosition=function(){return this.selection.getCursor()},this.getCursorPositionScreen=function(){return this.session.documentToScreenPosition(this.getCursorPosition())},this.getSelectionRange=function(){return this.selection.getRange()},this.selectAll=function(){this.$blockScrolling+=1,this.selection.selectAll(),this.$blockScrolling-=1},this.clearSelection=function(){this.selection.clearSelection()},this.moveCursorTo=function(e,t){this.selection.moveCursorTo(e,t)},this.moveCursorToPosition=function(e){this.selection.moveCursorToPosition(e)},this.jumpToMatching=function(e,t){var n=this.getCursorPosition(),r=new y(this.session,n.row,n.column),i=r.getCurrentToken(),s=i||r.stepForward();if(!s)return;var o,u=!1,a={},f=n.column-s.start,l,c={\")\":\"(\",\"(\":\"(\",\"]\":\"[\",\"[\":\"[\",\"{\":\"{\",\"}\":\"{\"};do{if(s.value.match(/[{}()\\[\\]]/g))for(;f<s.value.length&&!u;f++){if(!c[s.value[f]])continue;l=c[s.value[f]]+\".\"+s.type.replace(\"rparen\",\"lparen\"),isNaN(a[l])&&(a[l]=0);switch(s.value[f]){case\"(\":case\"[\":case\"{\":a[l]++;break;case\")\":case\"]\":case\"}\":a[l]--,a[l]===-1&&(o=\"bracket\",u=!0)}}else s&&s.type.indexOf(\"tag-name\")!==-1&&(isNaN(a[s.value])&&(a[s.value]=0),i.value===\"<\"?a[s.value]++:i.value===\"</\"&&a[s.value]--,a[s.value]===-1&&(o=\"tag\",u=!0));u||(i=s,s=r.stepForward(),f=0)}while(s&&!u);if(!o)return;var h,d;if(o===\"bracket\"){h=this.session.getBracketRange(n);if(!h){h=new p(r.getCurrentTokenRow(),r.getCurrentTokenColumn()+f-1,r.getCurrentTokenRow(),r.getCurrentTokenColumn()+f-1),d=h.start;if(t||d.row===n.row&&Math.abs(d.column-n.column)<2)h=this.session.getBracketRange(d)}}else if(o===\"tag\"){if(!s||s.type.indexOf(\"tag-name\")===-1)return;var v=s.value;h=new p(r.getCurrentTokenRow(),r.getCurrentTokenColumn()-2,r.getCurrentTokenRow(),r.getCurrentTokenColumn()-2);if(h.compare(n.row,n.column)===0){u=!1;do s=i,i=r.stepBackward(),i&&(i.type.indexOf(\"tag-close\")!==-1&&h.setEnd(r.getCurrentTokenRow(),r.getCurrentTokenColumn()+1),s.value===v&&s.type.indexOf(\"tag-name\")!==-1&&(i.value===\"<\"?a[v]++:i.value===\"</\"&&a[v]--,a[v]===0&&(u=!0)));while(i&&!u)}s&&s.type.indexOf(\"tag-name\")&&(d=h.start,d.row==n.row&&Math.abs(d.column-n.column)<2&&(d=h.end))}d=h&&h.cursor||d,d&&(e?h&&t?this.selection.setRange(h):h&&h.isEqual(this.getSelectionRange())?this.clearSelection():this.selection.selectTo(d.row,d.column):this.selection.moveTo(d.row,d.column))},this.gotoLine=function(e,t,n){this.selection.clearSelection(),this.session.unfold({row:e-1,column:t||0}),this.$blockScrolling+=1,this.exitMultiSelectMode&&this.exitMultiSelectMode(),this.moveCursorTo(e-1,t||0),this.$blockScrolling-=1,this.isRowFullyVisible(e-1)||this.scrollToLine(e-1,!0,n)},this.navigateTo=function(e,t){this.selection.moveTo(e,t)},this.navigateUp=function(e){if(this.selection.isMultiLine()&&!this.selection.isBackwards()){var t=this.selection.anchor.getPosition();return this.moveCursorToPosition(t)}this.selection.clearSelection(),this.selection.moveCursorBy(-e||-1,0)},this.navigateDown=function(e){if(this.selection.isMultiLine()&&this.selection.isBackwards()){var t=this.selection.anchor.getPosition();return this.moveCursorToPosition(t)}this.selection.clearSelection(),this.selection.moveCursorBy(e||1,0)},this.navigateLeft=function(e){if(!this.selection.isEmpty()){var t=this.getSelectionRange().start;this.moveCursorToPosition(t)}else{e=e||1;while(e--)this.selection.moveCursorLeft()}this.clearSelection()},this.navigateRight=function(e){if(!this.selection.isEmpty()){var t=this.getSelectionRange().end;this.moveCursorToPosition(t)}else{e=e||1;while(e--)this.selection.moveCursorRight()}this.clearSelection()},this.navigateLineStart=function(){this.selection.moveCursorLineStart(),this.clearSelection()},this.navigateLineEnd=function(){this.selection.moveCursorLineEnd(),this.clearSelection()},this.navigateFileEnd=function(){this.selection.moveCursorFileEnd(),this.clearSelection()},this.navigateFileStart=function(){this.selection.moveCursorFileStart(),this.clearSelection()},this.navigateWordRight=function(){this.selection.moveCursorWordRight(),this.clearSelection()},this.navigateWordLeft=function(){this.selection.moveCursorWordLeft(),this.clearSelection()},this.replace=function(e,t){t&&this.$search.set(t);var n=this.$search.find(this.session),r=0;return n?(this.$tryReplace(n,e)&&(r=1),n!==null&&(this.selection.setSelectionRange(n),this.renderer.scrollSelectionIntoView(n.start,n.end)),r):r},this.replaceAll=function(e,t){t&&this.$search.set(t);var n=this.$search.findAll(this.session),r=0;if(!n.length)return r;this.$blockScrolling+=1;var i=this.getSelectionRange();this.selection.moveTo(0,0);for(var s=n.length-1;s>=0;--s)this.$tryReplace(n[s],e)&&r++;return this.selection.setSelectionRange(i),this.$blockScrolling-=1,r},this.$tryReplace=function(e,t){var n=this.session.getTextRange(e);return t=this.$search.replace(n,t),t!==null?(e.end=this.session.replace(e,t),e):null},this.getLastSearchOptions=function(){return this.$search.getOptions()},this.find=function(e,t,n){t||(t={}),typeof e==\"string\"||e instanceof RegExp?t.needle=e:typeof e==\"object\"&&r.mixin(t,e);var i=this.selection.getRange();t.needle==null&&(e=this.session.getTextRange(i)||this.$search.$options.needle,e||(i=this.session.getWordRange(i.start.row,i.start.column),e=this.session.getTextRange(i)),this.$search.set({needle:e})),this.$search.set(t),t.start||this.$search.set({start:i});var s=this.$search.find(this.session);if(t.preventScroll)return s;if(s)return this.revealRange(s,n),s;t.backwards?i.start=i.end:i.end=i.start,this.selection.setRange(i)},this.findNext=function(e,t){this.find({skipCurrent:!0,backwards:!1},e,t)},this.findPrevious=function(e,t){this.find(e,{skipCurrent:!0,backwards:!0},t)},this.revealRange=function(e,t){this.$blockScrolling+=1,this.session.unfold(e),this.selection.setSelectionRange(e),this.$blockScrolling-=1;var n=this.renderer.scrollTop;this.renderer.scrollSelectionIntoView(e.start,e.end,.5),t!==!1&&this.renderer.animateScrolling(n)},this.undo=function(){this.$blockScrolling++,this.session.getUndoManager().undo(),this.$blockScrolling--,this.renderer.scrollCursorIntoView(null,.5)},this.redo=function(){this.$blockScrolling++,this.session.getUndoManager().redo(),this.$blockScrolling--,this.renderer.scrollCursorIntoView(null,.5)},this.destroy=function(){this.renderer.destroy(),this._signal(\"destroy\",this),this.session&&this.session.destroy()},this.setAutoScrollEditorIntoView=function(e){if(!e)return;var t,n=this,r=!1;this.$scrollAnchor||(this.$scrollAnchor=document.createElement(\"div\"));var i=this.$scrollAnchor;i.style.cssText=\"position:absolute\",this.container.insertBefore(i,this.container.firstChild);var s=this.on(\"changeSelection\",function(){r=!0}),o=this.renderer.on(\"beforeRender\",function(){r&&(t=n.renderer.container.getBoundingClientRect())}),u=this.renderer.on(\"afterRender\",function(){if(r&&t&&(n.isFocused()||n.searchBox&&n.searchBox.isFocused())){var e=n.renderer,s=e.$cursorLayer.$pixelPos,o=e.layerConfig,u=s.top-o.offset;s.top>=0&&u+t.top<0?r=!0:s.top<o.height&&s.top+t.top+o.lineHeight>window.innerHeight?r=!1:r=null,r!=null&&(i.style.top=u+\"px\",i.style.left=s.left+\"px\",i.style.height=o.lineHeight+\"px\",i.scrollIntoView(r)),r=t=null}});this.setAutoScrollEditorIntoView=function(e){if(e)return;delete this.setAutoScrollEditorIntoView,this.removeEventListener(\"changeSelection\",s),this.renderer.removeEventListener(\"afterRender\",u),this.renderer.removeEventListener(\"beforeRender\",o)}},this.$resetCursorStyle=function(){var e=this.$cursorStyle||\"ace\",t=this.renderer.$cursorLayer;if(!t)return;t.setSmoothBlinking(/smooth/.test(e)),t.isBlinking=!this.$readOnly&&e!=\"wide\",i.setCssClass(t.element,\"ace_slim-cursors\",/slim/.test(e))}}).call(b.prototype),g.defineOptions(b.prototype,\"editor\",{selectionStyle:{set:function(e){this.onSelectionChange(),this._signal(\"changeSelectionStyle\",{data:e})},initialValue:\"line\"},highlightActiveLine:{set:function(){this.$updateHighlightActiveLine()},initialValue:!0},highlightSelectedWord:{set:function(e){this.$onSelectionChange()},initialValue:!0},readOnly:{set:function(e){this.$resetCursorStyle()},initialValue:!1},cursorStyle:{set:function(e){this.$resetCursorStyle()},values:[\"ace\",\"slim\",\"smooth\",\"wide\"],initialValue:\"ace\"},mergeUndoDeltas:{values:[!1,!0,\"always\"],initialValue:!0},behavioursEnabled:{initialValue:!0},wrapBehavioursEnabled:{initialValue:!0},autoScrollEditorIntoView:{set:function(e){this.setAutoScrollEditorIntoView(e)}},hScrollBarAlwaysVisible:\"renderer\",vScrollBarAlwaysVisible:\"renderer\",highlightGutterLine:\"renderer\",animatedScroll:\"renderer\",showInvisibles:\"renderer\",showPrintMargin:\"renderer\",printMarginColumn:\"renderer\",printMargin:\"renderer\",fadeFoldWidgets:\"renderer\",showFoldWidgets:\"renderer\",showLineNumbers:\"renderer\",showGutter:\"renderer\",displayIndentGuides:\"renderer\",fontSize:\"renderer\",fontFamily:\"renderer\",maxLines:\"renderer\",minLines:\"renderer\",scrollPastEnd:\"renderer\",fixedWidthGutter:\"renderer\",theme:\"renderer\",scrollSpeed:\"$mouseHandler\",dragDelay:\"$mouseHandler\",dragEnabled:\"$mouseHandler\",focusTimout:\"$mouseHandler\",tooltipFollowsMouse:\"$mouseHandler\",firstLineNumber:\"session\",overwrite:\"session\",newLineMode:\"session\",useWorker:\"session\",useSoftTabs:\"session\",tabSize:\"session\",wrap:\"session\",indentedSoftWrap:\"session\",foldStyle:\"session\",mode:\"session\"}),t.Editor=b}),define(\"ace/undomanager\",[\"require\",\"exports\",\"module\"],function(e,t,n){\"use strict\";var r=function(){this.reset()};(function(){function e(e){return{action:e.action,start:e.start,end:e.end,lines:e.lines.length==1?null:e.lines,text:e.lines.length==1?e.lines[0]:null}}function t(e){return{action:e.action,start:e.start,end:e.end,lines:e.lines||[e.text]}}function n(e,t){var n=new Array(e.length);for(var r=0;r<e.length;r++){var i=e[r],s={group:i.group,deltas:new Array(i.length)};for(var o=0;o<i.deltas.length;o++){var u=i.deltas[o];s.deltas[o]=t(u)}n[r]=s}return n}this.execute=function(e){var t=e.args[0];this.$doc=e.args[1],e.merge&&this.hasUndo()&&(this.dirtyCounter--,t=this.$undoStack.pop().concat(t)),this.$undoStack.push(t),this.$redoStack=[],this.dirtyCounter<0&&(this.dirtyCounter=NaN),this.dirtyCounter++},this.undo=function(e){var t=this.$undoStack.pop(),n=null;return t&&(n=this.$doc.undoChanges(this.$deserializeDeltas(t),e),this.$redoStack.push(t),this.dirtyCounter--),n},this.redo=function(e){var t=this.$redoStack.pop(),n=null;return t&&(n=this.$doc.redoChanges(this.$deserializeDeltas(t),e),this.$undoStack.push(t),this.dirtyCounter++),n},this.reset=function(){this.$undoStack=[],this.$redoStack=[],this.dirtyCounter=0},this.hasUndo=function(){return this.$undoStack.length>0},this.hasRedo=function(){return this.$redoStack.length>0},this.markClean=function(){this.dirtyCounter=0},this.isClean=function(){return this.dirtyCounter===0},this.$serializeDeltas=function(t){return n(t,e)},this.$deserializeDeltas=function(e){return n(e,t)}}).call(r.prototype),t.UndoManager=r}),define(\"ace/layer/gutter\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"../lib/dom\"),i=e(\"../lib/oop\"),s=e(\"../lib/lang\"),o=e(\"../lib/event_emitter\").EventEmitter,u=function(e){this.element=r.createElement(\"div\"),this.element.className=\"ace_layer ace_gutter-layer\",e.appendChild(this.element),this.setShowFoldWidgets(this.$showFoldWidgets),this.gutterWidth=0,this.$annotations=[],this.$updateAnnotations=this.$updateAnnotations.bind(this),this.$cells=[]};(function(){i.implement(this,o),this.setSession=function(e){this.session&&this.session.removeEventListener(\"change\",this.$updateAnnotations),this.session=e,e&&e.on(\"change\",this.$updateAnnotations)},this.addGutterDecoration=function(e,t){window.console&&console.warn&&console.warn(\"deprecated use session.addGutterDecoration\"),this.session.addGutterDecoration(e,t)},this.removeGutterDecoration=function(e,t){window.console&&console.warn&&console.warn(\"deprecated use session.removeGutterDecoration\"),this.session.removeGutterDecoration(e,t)},this.setAnnotations=function(e){this.$annotations=[];for(var t=0;t<e.length;t++){var n=e[t],r=n.row,i=this.$annotations[r];i||(i=this.$annotations[r]={text:[]});var o=n.text;o=o?s.escapeHTML(o):n.html||\"\",i.text.indexOf(o)===-1&&i.text.push(o);var u=n.type;u==\"error\"?i.className=\" ace_error\":u==\"warning\"&&i.className!=\" ace_error\"?i.className=\" ace_warning\":u==\"info\"&&!i.className&&(i.className=\" ace_info\")}},this.$updateAnnotations=function(e){if(!this.$annotations.length)return;var t=e.start.row,n=e.end.row-t;if(n!==0)if(e.action==\"remove\")this.$annotations.splice(t,n+1,null);else{var r=new Array(n+1);r.unshift(t,1),this.$annotations.splice.apply(this.$annotations,r)}},this.update=function(e){var t=this.session,n=e.firstRow,i=Math.min(e.lastRow+e.gutterOffset,t.getLength()-1),s=t.getNextFoldLine(n),o=s?s.start.row:Infinity,u=this.$showFoldWidgets&&t.foldWidgets,a=t.$breakpoints,f=t.$decorations,l=t.$firstLineNumber,c=0,h=t.gutterRenderer||this.$renderer,p=null,d=-1,v=n;for(;;){v>o&&(v=s.end.row+1,s=t.getNextFoldLine(v,s),o=s?s.start.row:Infinity);if(v>i){while(this.$cells.length>d+1)p=this.$cells.pop(),this.element.removeChild(p.element);break}p=this.$cells[++d],p||(p={element:null,textNode:null,foldWidget:null},p.element=r.createElement(\"div\"),p.textNode=document.createTextNode(\"\"),p.element.appendChild(p.textNode),this.element.appendChild(p.element),this.$cells[d]=p);var m=\"ace_gutter-cell \";a[v]&&(m+=a[v]),f[v]&&(m+=f[v]),this.$annotations[v]&&(m+=this.$annotations[v].className),p.element.className!=m&&(p.element.className=m);var g=t.getRowLength(v)*e.lineHeight+\"px\";g!=p.element.style.height&&(p.element.style.height=g);if(u){var y=u[v];y==null&&(y=u[v]=t.getFoldWidget(v))}if(y){p.foldWidget||(p.foldWidget=r.createElement(\"span\"),p.element.appendChild(p.foldWidget));var m=\"ace_fold-widget ace_\"+y;y==\"start\"&&v==o&&v<s.end.row?m+=\" ace_closed\":m+=\" ace_open\",p.foldWidget.className!=m&&(p.foldWidget.className=m);var g=e.lineHeight+\"px\";p.foldWidget.style.height!=g&&(p.foldWidget.style.height=g)}else p.foldWidget&&(p.element.removeChild(p.foldWidget),p.foldWidget=null);var b=c=h?h.getText(t,v):v+l;b!=p.textNode.data&&(p.textNode.data=b),v++}this.element.style.height=e.minHeight+\"px\";if(this.$fixedWidth||t.$useWrapMode)c=t.getLength()+l;var w=h?h.getWidth(t,c,e):c.toString().length*e.characterWidth,E=this.$padding||this.$computePadding();w+=E.left+E.right,w!==this.gutterWidth&&!isNaN(w)&&(this.gutterWidth=w,this.element.style.width=Math.ceil(this.gutterWidth)+\"px\",this._emit(\"changeGutterWidth\",w))},this.$fixedWidth=!1,this.$showLineNumbers=!0,this.$renderer=\"\",this.setShowLineNumbers=function(e){this.$renderer=!e&&{getWidth:function(){return\"\"},getText:function(){return\"\"}}},this.getShowLineNumbers=function(){return this.$showLineNumbers},this.$showFoldWidgets=!0,this.setShowFoldWidgets=function(e){e?r.addCssClass(this.element,\"ace_folding-enabled\"):r.removeCssClass(this.element,\"ace_folding-enabled\"),this.$showFoldWidgets=e,this.$padding=null},this.getShowFoldWidgets=function(){return this.$showFoldWidgets},this.$computePadding=function(){if(!this.element.firstChild)return{left:0,right:0};var e=r.computedStyle(this.element.firstChild);return this.$padding={},this.$padding.left=parseInt(e.paddingLeft)+1||0,this.$padding.right=parseInt(e.paddingRight)||0,this.$padding},this.getRegion=function(e){var t=this.$padding||this.$computePadding(),n=this.element.getBoundingClientRect();if(e.x<t.left+n.left)return\"markers\";if(this.$showFoldWidgets&&e.x>n.right-t.right)return\"foldWidgets\"}}).call(u.prototype),t.Gutter=u}),define(\"ace/layer/marker\",[\"require\",\"exports\",\"module\",\"ace/range\",\"ace/lib/dom\"],function(e,t,n){\"use strict\";var r=e(\"../range\").Range,i=e(\"../lib/dom\"),s=function(e){this.element=i.createElement(\"div\"),this.element.className=\"ace_layer ace_marker-layer\",e.appendChild(this.element)};(function(){function e(e,t,n,r){return(e?1:0)|(t?2:0)|(n?4:0)|(r?8:0)}this.$padding=0,this.setPadding=function(e){this.$padding=e},this.setSession=function(e){this.session=e},this.setMarkers=function(e){this.markers=e},this.update=function(e){var e=e||this.config;if(!e)return;this.config=e;var t=[];for(var n in this.markers){var r=this.markers[n];if(!r.range){r.update(t,this,this.session,e);continue}var i=r.range.clipRows(e.firstRow,e.lastRow);if(i.isEmpty())continue;i=i.toScreenRange(this.session);if(r.renderer){var s=this.$getTop(i.start.row,e),o=this.$padding+i.start.column*e.characterWidth;r.renderer(t,i,o,s,e)}else r.type==\"fullLine\"?this.drawFullLineMarker(t,i,r.clazz,e):r.type==\"screenLine\"?this.drawScreenLineMarker(t,i,r.clazz,e):i.isMultiLine()?r.type==\"text\"?this.drawTextMarker(t,i,r.clazz,e):this.drawMultiLineMarker(t,i,r.clazz,e):this.drawSingleLineMarker(t,i,r.clazz+\" ace_start\"+\" ace_br15\",e)}this.element.innerHTML=t.join(\"\")},this.$getTop=function(e,t){return(e-t.firstRowScreen)*t.lineHeight},this.drawTextMarker=function(t,n,i,s,o){var u=this.session,a=n.start.row,f=n.end.row,l=a,c=0,h=0,p=u.getScreenLastRowColumn(l),d=new r(l,n.start.column,l,h);for(;l<=f;l++)d.start.row=d.end.row=l,d.start.column=l==a?n.start.column:u.getRowWrapIndent(l),d.end.column=p,c=h,h=p,p=l+1<f?u.getScreenLastRowColumn(l+1):l==f?0:n.end.column,this.drawSingleLineMarker(t,d,i+(l==a?\" ace_start\":\"\")+\" ace_br\"+e(l==a||l==a+1&&n.start.column,c<h,h>p,l==f),s,l==f?0:1,o)},this.drawMultiLineMarker=function(e,t,n,r,i){var s=this.$padding,o=r.lineHeight,u=this.$getTop(t.start.row,r),a=s+t.start.column*r.characterWidth;i=i||\"\",e.push(\"<div class='\",n,\" ace_br1 ace_start' style='\",\"height:\",o,\"px;\",\"right:0;\",\"top:\",u,\"px;\",\"left:\",a,\"px;\",i,\"'></div>\"),u=this.$getTop(t.end.row,r);var f=t.end.column*r.characterWidth;e.push(\"<div class='\",n,\" ace_br12' style='\",\"height:\",o,\"px;\",\"width:\",f,\"px;\",\"top:\",u,\"px;\",\"left:\",s,\"px;\",i,\"'></div>\"),o=(t.end.row-t.start.row-1)*r.lineHeight;if(o<=0)return;u=this.$getTop(t.start.row+1,r);var l=(t.start.column?1:0)|(t.end.column?0:8);e.push(\"<div class='\",n,l?\" ace_br\"+l:\"\",\"' style='\",\"height:\",o,\"px;\",\"right:0;\",\"top:\",u,\"px;\",\"left:\",s,\"px;\",i,\"'></div>\")},this.drawSingleLineMarker=function(e,t,n,r,i,s){var o=r.lineHeight,u=(t.end.column+(i||0)-t.start.column)*r.characterWidth,a=this.$getTop(t.start.row,r),f=this.$padding+t.start.column*r.characterWidth;e.push(\"<div class='\",n,\"' style='\",\"height:\",o,\"px;\",\"width:\",u,\"px;\",\"top:\",a,\"px;\",\"left:\",f,\"px;\",s||\"\",\"'></div>\")},this.drawFullLineMarker=function(e,t,n,r,i){var s=this.$getTop(t.start.row,r),o=r.lineHeight;t.start.row!=t.end.row&&(o+=this.$getTop(t.end.row,r)-s),e.push(\"<div class='\",n,\"' style='\",\"height:\",o,\"px;\",\"top:\",s,\"px;\",\"left:0;right:0;\",i||\"\",\"'></div>\")},this.drawScreenLineMarker=function(e,t,n,r,i){var s=this.$getTop(t.start.row,r),o=r.lineHeight;e.push(\"<div class='\",n,\"' style='\",\"height:\",o,\"px;\",\"top:\",s,\"px;\",\"left:0;right:0;\",i||\"\",\"'></div>\")}}).call(s.prototype),t.Marker=s}),define(\"ace/layer/text\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/lib/lang\",\"ace/lib/useragent\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/dom\"),s=e(\"../lib/lang\"),o=e(\"../lib/useragent\"),u=e(\"../lib/event_emitter\").EventEmitter,a=function(e){this.element=i.createElement(\"div\"),this.element.className=\"ace_layer ace_text-layer\",e.appendChild(this.element),this.$updateEolChar=this.$updateEolChar.bind(this)};(function(){r.implement(this,u),this.EOF_CHAR=\"\\u00b6\",this.EOL_CHAR_LF=\"\\u00ac\",this.EOL_CHAR_CRLF=\"\\u00a4\",this.EOL_CHAR=this.EOL_CHAR_LF,this.TAB_CHAR=\"\\u2014\",this.SPACE_CHAR=\"\\u00b7\",this.$padding=0,this.$updateEolChar=function(){var e=this.session.doc.getNewLineCharacter()==\"\\n\"?this.EOL_CHAR_LF:this.EOL_CHAR_CRLF;if(this.EOL_CHAR!=e)return this.EOL_CHAR=e,!0},this.setPadding=function(e){this.$padding=e,this.element.style.padding=\"0 \"+e+\"px\"},this.getLineHeight=function(){return this.$fontMetrics.$characterSize.height||0},this.getCharacterWidth=function(){return this.$fontMetrics.$characterSize.width||0},this.$setFontMetrics=function(e){this.$fontMetrics=e,this.$fontMetrics.on(\"changeCharacterSize\",function(e){this._signal(\"changeCharacterSize\",e)}.bind(this)),this.$pollSizeChanges()},this.checkForSizeChanges=function(){this.$fontMetrics.checkForSizeChanges()},this.$pollSizeChanges=function(){return this.$pollSizeChangesTimer=this.$fontMetrics.$pollSizeChanges()},this.setSession=function(e){this.session=e,e&&this.$computeTabString()},this.showInvisibles=!1,this.setShowInvisibles=function(e){return this.showInvisibles==e?!1:(this.showInvisibles=e,this.$computeTabString(),!0)},this.displayIndentGuides=!0,this.setDisplayIndentGuides=function(e){return this.displayIndentGuides==e?!1:(this.displayIndentGuides=e,this.$computeTabString(),!0)},this.$tabStrings=[],this.onChangeTabSize=this.$computeTabString=function(){var e=this.session.getTabSize();this.tabSize=e;var t=this.$tabStrings=[0];for(var n=1;n<e+1;n++)this.showInvisibles?t.push(\"<span class='ace_invisible ace_invisible_tab'>\"+s.stringRepeat(this.TAB_CHAR,n)+\"</span>\"):t.push(s.stringRepeat(\" \",n));if(this.displayIndentGuides){this.$indentGuideRe=/\\s\\S| \\t|\\t |\\s$/;var r=\"ace_indent-guide\",i=\"\",o=\"\";if(this.showInvisibles){r+=\" ace_invisible\",i=\" ace_invisible_space\",o=\" ace_invisible_tab\";var u=s.stringRepeat(this.SPACE_CHAR,this.tabSize),a=s.stringRepeat(this.TAB_CHAR,this.tabSize)}else var u=s.stringRepeat(\" \",this.tabSize),a=u;this.$tabStrings[\" \"]=\"<span class='\"+r+i+\"'>\"+u+\"</span>\",this.$tabStrings[\"\t\"]=\"<span class='\"+r+o+\"'>\"+a+\"</span>\"}},this.updateLines=function(e,t,n){(this.config.lastRow!=e.lastRow||this.config.firstRow!=e.firstRow)&&this.scrollLines(e),this.config=e;var r=Math.max(t,e.firstRow),i=Math.min(n,e.lastRow),s=this.element.childNodes,o=0;for(var u=e.firstRow;u<r;u++){var a=this.session.getFoldLine(u);if(a){if(a.containsRow(r)){r=a.start.row;break}u=a.end.row}o++}var u=r,a=this.session.getNextFoldLine(u),f=a?a.start.row:Infinity;for(;;){u>f&&(u=a.end.row+1,a=this.session.getNextFoldLine(u,a),f=a?a.start.row:Infinity);if(u>i)break;var l=s[o++];if(l){var c=[];this.$renderLine(c,u,!this.$useLineGroups(),u==f?a:!1),l.style.height=e.lineHeight*this.session.getRowLength(u)+\"px\",l.innerHTML=c.join(\"\")}u++}},this.scrollLines=function(e){var t=this.config;this.config=e;if(!t||t.lastRow<e.firstRow)return this.update(e);if(e.lastRow<t.firstRow)return this.update(e);var n=this.element;if(t.firstRow<e.firstRow)for(var r=this.session.getFoldedRowCount(t.firstRow,e.firstRow-1);r>0;r--)n.removeChild(n.firstChild);if(t.lastRow>e.lastRow)for(var r=this.session.getFoldedRowCount(e.lastRow+1,t.lastRow);r>0;r--)n.removeChild(n.lastChild);if(e.firstRow<t.firstRow){var i=this.$renderLinesFragment(e,e.firstRow,t.firstRow-1);n.firstChild?n.insertBefore(i,n.firstChild):n.appendChild(i)}if(e.lastRow>t.lastRow){var i=this.$renderLinesFragment(e,t.lastRow+1,e.lastRow);n.appendChild(i)}},this.$renderLinesFragment=function(e,t,n){var r=this.element.ownerDocument.createDocumentFragment(),s=t,o=this.session.getNextFoldLine(s),u=o?o.start.row:Infinity;for(;;){s>u&&(s=o.end.row+1,o=this.session.getNextFoldLine(s,o),u=o?o.start.row:Infinity);if(s>n)break;var a=i.createElement(\"div\"),f=[];this.$renderLine(f,s,!1,s==u?o:!1),a.innerHTML=f.join(\"\");if(this.$useLineGroups())a.className=\"ace_line_group\",r.appendChild(a),a.style.height=e.lineHeight*this.session.getRowLength(s)+\"px\";else while(a.firstChild)r.appendChild(a.firstChild);s++}return r},this.update=function(e){this.config=e;var t=[],n=e.firstRow,r=e.lastRow,i=n,s=this.session.getNextFoldLine(i),o=s?s.start.row:Infinity;for(;;){i>o&&(i=s.end.row+1,s=this.session.getNextFoldLine(i,s),o=s?s.start.row:Infinity);if(i>r)break;this.$useLineGroups()&&t.push(\"<div class='ace_line_group' style='height:\",e.lineHeight*this.session.getRowLength(i),\"px'>\"),this.$renderLine(t,i,!1,i==o?s:!1),this.$useLineGroups()&&t.push(\"</div>\"),i++}this.element.innerHTML=t.join(\"\")},this.$textToken={text:!0,rparen:!0,lparen:!0},this.$renderToken=function(e,t,n,r){var i=this,o=/\\t|&|<|>|( +)|([\\x00-\\x1f\\x80-\\xa0\\xad\\u1680\\u180E\\u2000-\\u200f\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF])|[\\u1100-\\u115F\\u11A3-\\u11A7\\u11FA-\\u11FF\\u2329-\\u232A\\u2E80-\\u2E99\\u2E9B-\\u2EF3\\u2F00-\\u2FD5\\u2FF0-\\u2FFB\\u3000-\\u303E\\u3041-\\u3096\\u3099-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u3190-\\u31BA\\u31C0-\\u31E3\\u31F0-\\u321E\\u3220-\\u3247\\u3250-\\u32FE\\u3300-\\u4DBF\\u4E00-\\uA48C\\uA490-\\uA4C6\\uA960-\\uA97C\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFAFF\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE66\\uFE68-\\uFE6B\\uFF01-\\uFF60\\uFFE0-\\uFFE6]/g,u=function(e,n,r,o,u){if(n)return i.showInvisibles?\"<span class='ace_invisible ace_invisible_space'>\"+s.stringRepeat(i.SPACE_CHAR,e.length)+\"</span>\":e;if(e==\"&\")return\"&#38;\";if(e==\"<\")return\"&#60;\";if(e==\">\")return\"&#62;\";if(e==\"\t\"){var a=i.session.getScreenTabSize(t+o);return t+=a-1,i.$tabStrings[a]}if(e==\"\\u3000\"){var f=i.showInvisibles?\"ace_cjk ace_invisible ace_invisible_space\":\"ace_cjk\",l=i.showInvisibles?i.SPACE_CHAR:\"\";return t+=1,\"<span class='\"+f+\"' style='width:\"+i.config.characterWidth*2+\"px'>\"+l+\"</span>\"}return r?\"<span class='ace_invisible ace_invisible_space ace_invalid'>\"+i.SPACE_CHAR+\"</span>\":(t+=1,\"<span class='ace_cjk' style='width:\"+i.config.characterWidth*2+\"px'>\"+e+\"</span>\")},a=r.replace(o,u);if(!this.$textToken[n.type]){var f=\"ace_\"+n.type.replace(/\\./g,\" ace_\"),l=\"\";n.type==\"fold\"&&(l=\" style='width:\"+n.value.length*this.config.characterWidth+\"px;' \"),e.push(\"<span class='\",f,\"'\",l,\">\",a,\"</span>\")}else e.push(a);return t+r.length},this.renderIndentGuide=function(e,t,n){var r=t.search(this.$indentGuideRe);return r<=0||r>=n?t:t[0]==\" \"?(r-=r%this.tabSize,e.push(s.stringRepeat(this.$tabStrings[\" \"],r/this.tabSize)),t.substr(r)):t[0]==\"\t\"?(e.push(s.stringRepeat(this.$tabStrings[\"\t\"],r)),t.substr(r)):t},this.$renderWrappedLine=function(e,t,n,r){var i=0,o=0,u=n[0],a=0;for(var f=0;f<t.length;f++){var l=t[f],c=l.value;if(f==0&&this.displayIndentGuides){i=c.length,c=this.renderIndentGuide(e,c,u);if(!c)continue;i-=c.length}if(i+c.length<u)a=this.$renderToken(e,a,l,c),i+=c.length;else{while(i+c.length>=u)a=this.$renderToken(e,a,l,c.substring(0,u-i)),c=c.substring(u-i),i=u,r||e.push(\"</div>\",\"<div class='ace_line' style='height:\",this.config.lineHeight,\"px'>\"),e.push(s.stringRepeat(\"\\u00a0\",n.indent)),o++,a=0,u=n[o]||Number.MAX_VALUE;c.length!=0&&(i+=c.length,a=this.$renderToken(e,a,l,c))}}},this.$renderSimpleLine=function(e,t){var n=0,r=t[0],i=r.value;this.displayIndentGuides&&(i=this.renderIndentGuide(e,i)),i&&(n=this.$renderToken(e,n,r,i));for(var s=1;s<t.length;s++)r=t[s],i=r.value,n=this.$renderToken(e,n,r,i)},this.$renderLine=function(e,t,n,r){!r&&r!=0&&(r=this.session.getFoldLine(t));if(r)var i=this.$getFoldLineTokens(t,r);else var i=this.session.getTokens(t);n||e.push(\"<div class='ace_line' style='height:\",this.config.lineHeight*(this.$useLineGroups()?1:this.session.getRowLength(t)),\"px'>\");if(i.length){var s=this.session.getRowSplitData(t);s&&s.length?this.$renderWrappedLine(e,i,s,n):this.$renderSimpleLine(e,i)}this.showInvisibles&&(r&&(t=r.end.row),e.push(\"<span class='ace_invisible ace_invisible_eol'>\",t==this.session.getLength()-1?this.EOF_CHAR:this.EOL_CHAR,\"</span>\")),n||e.push(\"</div>\")},this.$getFoldLineTokens=function(e,t){function i(e,t,n){var i=0,s=0;while(s+e[i].value.length<t){s+=e[i].value.length,i++;if(i==e.length)return}if(s!=t){var o=e[i].value.substring(t-s);o.length>n-t&&(o=o.substring(0,n-t)),r.push({type:e[i].type,value:o}),s=t+o.length,i+=1}while(s<n&&i<e.length){var o=e[i].value;o.length+s>n?r.push({type:e[i].type,value:o.substring(0,n-s)}):r.push(e[i]),s+=o.length,i+=1}}var n=this.session,r=[],s=n.getTokens(e);return t.walk(function(e,t,o,u,a){e!=null?r.push({type:\"fold\",value:e}):(a&&(s=n.getTokens(t)),s.length&&i(s,u,o))},t.end.row,this.session.getLine(t.end.row).length),r},this.$useLineGroups=function(){return this.session.getUseWrapMode()},this.destroy=function(){clearInterval(this.$pollSizeChangesTimer),this.$measureNode&&this.$measureNode.parentNode.removeChild(this.$measureNode),delete this.$measureNode}}).call(a.prototype),t.Text=a}),define(\"ace/layer/cursor\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\"],function(e,t,n){\"use strict\";var r=e(\"../lib/dom\"),i,s=function(e){this.element=r.createElement(\"div\"),this.element.className=\"ace_layer ace_cursor-layer\",e.appendChild(this.element),i===undefined&&(i=!(\"opacity\"in this.element.style)),this.isVisible=!1,this.isBlinking=!0,this.blinkInterval=1e3,this.smoothBlinking=!1,this.cursors=[],this.cursor=this.addCursor(),r.addCssClass(this.element,\"ace_hidden-cursors\"),this.$updateCursors=(i?this.$updateVisibility:this.$updateOpacity).bind(this)};(function(){this.$updateVisibility=function(e){var t=this.cursors;for(var n=t.length;n--;)t[n].style.visibility=e?\"\":\"hidden\"},this.$updateOpacity=function(e){var t=this.cursors;for(var n=t.length;n--;)t[n].style.opacity=e?\"\":\"0\"},this.$padding=0,this.setPadding=function(e){this.$padding=e},this.setSession=function(e){this.session=e},this.setBlinking=function(e){e!=this.isBlinking&&(this.isBlinking=e,this.restartTimer())},this.setBlinkInterval=function(e){e!=this.blinkInterval&&(this.blinkInterval=e,this.restartTimer())},this.setSmoothBlinking=function(e){e!=this.smoothBlinking&&!i&&(this.smoothBlinking=e,r.setCssClass(this.element,\"ace_smooth-blinking\",e),this.$updateCursors(!0),this.$updateCursors=this.$updateOpacity.bind(this),this.restartTimer())},this.addCursor=function(){var e=r.createElement(\"div\");return e.className=\"ace_cursor\",this.element.appendChild(e),this.cursors.push(e),e},this.removeCursor=function(){if(this.cursors.length>1){var e=this.cursors.pop();return e.parentNode.removeChild(e),e}},this.hideCursor=function(){this.isVisible=!1,r.addCssClass(this.element,\"ace_hidden-cursors\"),this.restartTimer()},this.showCursor=function(){this.isVisible=!0,r.removeCssClass(this.element,\"ace_hidden-cursors\"),this.restartTimer()},this.restartTimer=function(){var e=this.$updateCursors;clearInterval(this.intervalId),clearTimeout(this.timeoutId),this.smoothBlinking&&r.removeCssClass(this.element,\"ace_smooth-blinking\"),e(!0);if(!this.isBlinking||!this.blinkInterval||!this.isVisible)return;this.smoothBlinking&&setTimeout(function(){r.addCssClass(this.element,\"ace_smooth-blinking\")}.bind(this));var t=function(){this.timeoutId=setTimeout(function(){e(!1)},.6*this.blinkInterval)}.bind(this);this.intervalId=setInterval(function(){e(!0),t()},this.blinkInterval),t()},this.getPixelPosition=function(e,t){if(!this.config||!this.session)return{left:0,top:0};e||(e=this.session.selection.getCursor());var n=this.session.documentToScreenPosition(e),r=this.$padding+n.column*this.config.characterWidth,i=(n.row-(t?this.config.firstRowScreen:0))*this.config.lineHeight;return{left:r,top:i}},this.update=function(e){this.config=e;var t=this.session.$selectionMarkers,n=0,r=0;if(t===undefined||t.length===0)t=[{cursor:null}];for(var n=0,i=t.length;n<i;n++){var s=this.getPixelPosition(t[n].cursor,!0);if((s.top>e.height+e.offset||s.top<0)&&n>1)continue;var o=(this.cursors[r++]||this.addCursor()).style;this.drawCursor?this.drawCursor(o,s,e,t[n],this.session):(o.left=s.left+\"px\",o.top=s.top+\"px\",o.width=e.characterWidth+\"px\",o.height=e.lineHeight+\"px\")}while(this.cursors.length>r)this.removeCursor();var u=this.session.getOverwrite();this.$setOverwrite(u),this.$pixelPos=s,this.restartTimer()},this.drawCursor=null,this.$setOverwrite=function(e){e!=this.overwrite&&(this.overwrite=e,e?r.addCssClass(this.element,\"ace_overwrite-cursors\"):r.removeCssClass(this.element,\"ace_overwrite-cursors\"))},this.destroy=function(){clearInterval(this.intervalId),clearTimeout(this.timeoutId)}}).call(s.prototype),t.Cursor=s}),define(\"ace/scrollbar\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/lib/event\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/dom\"),s=e(\"./lib/event\"),o=e(\"./lib/event_emitter\").EventEmitter,u=function(e){this.element=i.createElement(\"div\"),this.element.className=\"ace_scrollbar ace_scrollbar\"+this.classSuffix,this.inner=i.createElement(\"div\"),this.inner.className=\"ace_scrollbar-inner\",this.element.appendChild(this.inner),e.appendChild(this.element),this.setVisible(!1),this.skipEvent=!1,s.addListener(this.element,\"scroll\",this.onScroll.bind(this)),s.addListener(this.element,\"mousedown\",s.preventDefault)};(function(){r.implement(this,o),this.setVisible=function(e){this.element.style.display=e?\"\":\"none\",this.isVisible=e}}).call(u.prototype);var a=function(e,t){u.call(this,e),this.scrollTop=0,t.$scrollbarWidth=this.width=i.scrollbarWidth(e.ownerDocument),this.inner.style.width=this.element.style.width=(this.width||15)+5+\"px\"};r.inherits(a,u),function(){this.classSuffix=\"-v\",this.onScroll=function(){this.skipEvent||(this.scrollTop=this.element.scrollTop,this._emit(\"scroll\",{data:this.scrollTop})),this.skipEvent=!1},this.getWidth=function(){return this.isVisible?this.width:0},this.setHeight=function(e){this.element.style.height=e+\"px\"},this.setInnerHeight=function(e){this.inner.style.height=e+\"px\"},this.setScrollHeight=function(e){this.inner.style.height=e+\"px\"},this.setScrollTop=function(e){this.scrollTop!=e&&(this.skipEvent=!0,this.scrollTop=this.element.scrollTop=e)}}.call(a.prototype);var f=function(e,t){u.call(this,e),this.scrollLeft=0,this.height=t.$scrollbarWidth,this.inner.style.height=this.element.style.height=(this.height||15)+5+\"px\"};r.inherits(f,u),function(){this.classSuffix=\"-h\",this.onScroll=function(){this.skipEvent||(this.scrollLeft=this.element.scrollLeft,this._emit(\"scroll\",{data:this.scrollLeft})),this.skipEvent=!1},this.getHeight=function(){return this.isVisible?this.height:0},this.setWidth=function(e){this.element.style.width=e+\"px\"},this.setInnerWidth=function(e){this.inner.style.width=e+\"px\"},this.setScrollWidth=function(e){this.inner.style.width=e+\"px\"},this.setScrollLeft=function(e){this.scrollLeft!=e&&(this.skipEvent=!0,this.scrollLeft=this.element.scrollLeft=e)}}.call(f.prototype),t.ScrollBar=a,t.ScrollBarV=a,t.ScrollBarH=f,t.VScrollBar=a,t.HScrollBar=f}),define(\"ace/renderloop\",[\"require\",\"exports\",\"module\",\"ace/lib/event\"],function(e,t,n){\"use strict\";var r=e(\"./lib/event\"),i=function(e,t){this.onRender=e,this.pending=!1,this.changes=0,this.window=t||window};(function(){this.schedule=function(e){this.changes=this.changes|e;if(!this.pending&&this.changes){this.pending=!0;var t=this;r.nextFrame(function(){t.pending=!1;var e;while(e=t.changes)t.changes=0,t.onRender(e)},this.window)}}}).call(i.prototype),t.RenderLoop=i}),define(\"ace/layer/font_metrics\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/lib/lang\",\"ace/lib/useragent\",\"ace/lib/event_emitter\"],function(e,t,n){var r=e(\"../lib/oop\"),i=e(\"../lib/dom\"),s=e(\"../lib/lang\"),o=e(\"../lib/useragent\"),u=e(\"../lib/event_emitter\").EventEmitter,a=0,f=t.FontMetrics=function(e,t){this.el=i.createElement(\"div\"),this.$setMeasureNodeStyles(this.el.style,!0),this.$main=i.createElement(\"div\"),this.$setMeasureNodeStyles(this.$main.style),this.$measureNode=i.createElement(\"div\"),this.$setMeasureNodeStyles(this.$measureNode.style),this.el.appendChild(this.$main),this.el.appendChild(this.$measureNode),e.appendChild(this.el),a||this.$testFractionalRect(),this.$measureNode.innerHTML=s.stringRepeat(\"X\",a),this.$characterSize={width:0,height:0},this.checkForSizeChanges()};(function(){r.implement(this,u),this.$characterSize={width:0,height:0},this.$testFractionalRect=function(){var e=i.createElement(\"div\");this.$setMeasureNodeStyles(e.style),e.style.width=\"0.2px\",document.documentElement.appendChild(e);var t=e.getBoundingClientRect().width;t>0&&t<1?a=50:a=100,e.parentNode.removeChild(e)},this.$setMeasureNodeStyles=function(e,t){e.width=e.height=\"auto\",e.left=e.top=\"0px\",e.visibility=\"hidden\",e.position=\"absolute\",e.whiteSpace=\"pre\",o.isIE<8?e[\"font-family\"]=\"inherit\":e.font=\"inherit\",e.overflow=t?\"hidden\":\"visible\"},this.checkForSizeChanges=function(){var e=this.$measureSizes();if(e&&(this.$characterSize.width!==e.width||this.$characterSize.height!==e.height)){this.$measureNode.style.fontWeight=\"bold\";var t=this.$measureSizes();this.$measureNode.style.fontWeight=\"\",this.$characterSize=e,this.charSizes=Object.create(null),this.allowBoldFonts=t&&t.width===e.width&&t.height===e.height,this._emit(\"changeCharacterSize\",{data:e})}},this.$pollSizeChanges=function(){if(this.$pollSizeChangesTimer)return this.$pollSizeChangesTimer;var e=this;return this.$pollSizeChangesTimer=setInterval(function(){e.checkForSizeChanges()},500)},this.setPolling=function(e){e?this.$pollSizeChanges():this.$pollSizeChangesTimer&&(clearInterval(this.$pollSizeChangesTimer),this.$pollSizeChangesTimer=0)},this.$measureSizes=function(){if(a===50){var e=null;try{e=this.$measureNode.getBoundingClientRect()}catch(t){e={width:0,height:0}}var n={height:e.height,width:e.width/a}}else var n={height:this.$measureNode.clientHeight,width:this.$measureNode.clientWidth/a};return n.width===0||n.height===0?null:n},this.$measureCharWidth=function(e){this.$main.innerHTML=s.stringRepeat(e,a);var t=this.$main.getBoundingClientRect();return t.width/a},this.getCharacterWidth=function(e){var t=this.charSizes[e];return t===undefined&&(this.charSizes[e]=this.$measureCharWidth(e)/this.$characterSize.width),t},this.destroy=function(){clearInterval(this.$pollSizeChangesTimer),this.el&&this.el.parentNode&&this.el.parentNode.removeChild(this.el)}}).call(f.prototype)}),define(\"ace/virtual_renderer\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/config\",\"ace/lib/useragent\",\"ace/layer/gutter\",\"ace/layer/marker\",\"ace/layer/text\",\"ace/layer/cursor\",\"ace/scrollbar\",\"ace/scrollbar\",\"ace/renderloop\",\"ace/layer/font_metrics\",\"ace/lib/event_emitter\"],function(e,t,n){\"use strict\";var r=e(\"./lib/oop\"),i=e(\"./lib/dom\"),s=e(\"./config\"),o=e(\"./lib/useragent\"),u=e(\"./layer/gutter\").Gutter,a=e(\"./layer/marker\").Marker,f=e(\"./layer/text\").Text,l=e(\"./layer/cursor\").Cursor,c=e(\"./scrollbar\").HScrollBar,h=e(\"./scrollbar\").VScrollBar,p=e(\"./renderloop\").RenderLoop,d=e(\"./layer/font_metrics\").FontMetrics,v=e(\"./lib/event_emitter\").EventEmitter,m='.ace_editor {position: relative;overflow: hidden;font: 12px/normal \\'Monaco\\', \\'Menlo\\', \\'Ubuntu Mono\\', \\'Consolas\\', \\'source-code-pro\\', monospace;direction: ltr;}.ace_scroller {position: absolute;overflow: hidden;top: 0;bottom: 0;background-color: inherit;-ms-user-select: none;-moz-user-select: none;-webkit-user-select: none;user-select: none;cursor: text;}.ace_content {position: absolute;-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;min-width: 100%;}.ace_dragging .ace_scroller:before{position: absolute;top: 0;left: 0;right: 0;bottom: 0;content: \\'\\';background: rgba(250, 250, 250, 0.01);z-index: 1000;}.ace_dragging.ace_dark .ace_scroller:before{background: rgba(0, 0, 0, 0.01);}.ace_selecting, .ace_selecting * {cursor: text !important;}.ace_gutter {position: absolute;overflow : hidden;width: auto;top: 0;bottom: 0;left: 0;cursor: default;z-index: 4;-ms-user-select: none;-moz-user-select: none;-webkit-user-select: none;user-select: none;}.ace_gutter-active-line {position: absolute;left: 0;right: 0;}.ace_scroller.ace_scroll-left {box-shadow: 17px 0 16px -16px rgba(0, 0, 0, 0.4) inset;}.ace_gutter-cell {padding-left: 19px;padding-right: 6px;background-repeat: no-repeat;}.ace_gutter-cell.ace_error {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAABOFBMVEX/////////QRswFAb/Ui4wFAYwFAYwFAaWGAfDRymzOSH/PxswFAb/SiUwFAYwFAbUPRvjQiDllog5HhHdRybsTi3/Tyv9Tir+Syj/UC3////XurebMBIwFAb/RSHbPx/gUzfdwL3kzMivKBAwFAbbvbnhPx66NhowFAYwFAaZJg8wFAaxKBDZurf/RB6mMxb/SCMwFAYwFAbxQB3+RB4wFAb/Qhy4Oh+4QifbNRcwFAYwFAYwFAb/QRzdNhgwFAYwFAbav7v/Uy7oaE68MBK5LxLewr/r2NXewLswFAaxJw4wFAbkPRy2PyYwFAaxKhLm1tMwFAazPiQwFAaUGAb/QBrfOx3bvrv/VC/maE4wFAbRPBq6MRO8Qynew8Dp2tjfwb0wFAbx6eju5+by6uns4uH9/f36+vr/GkHjAAAAYnRSTlMAGt+64rnWu/bo8eAA4InH3+DwoN7j4eLi4xP99Nfg4+b+/u9B/eDs1MD1mO7+4PHg2MXa347g7vDizMLN4eG+Pv7i5evs/v79yu7S3/DV7/498Yv24eH+4ufQ3Ozu/v7+y13sRqwAAADLSURBVHjaZc/XDsFgGIBhtDrshlitmk2IrbHFqL2pvXf/+78DPokj7+Fz9qpU/9UXJIlhmPaTaQ6QPaz0mm+5gwkgovcV6GZzd5JtCQwgsxoHOvJO15kleRLAnMgHFIESUEPmawB9ngmelTtipwwfASilxOLyiV5UVUyVAfbG0cCPHig+GBkzAENHS0AstVF6bacZIOzgLmxsHbt2OecNgJC83JERmePUYq8ARGkJx6XtFsdddBQgZE2nPR6CICZhawjA4Fb/chv+399kfR+MMMDGOQAAAABJRU5ErkJggg==\");background-repeat: no-repeat;background-position: 2px center;}.ace_gutter-cell.ace_warning {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAmVBMVEX///8AAAD///8AAAAAAABPSzb/5sAAAAB/blH/73z/ulkAAAAAAAD85pkAAAAAAAACAgP/vGz/rkDerGbGrV7/pkQICAf////e0IsAAAD/oED/qTvhrnUAAAD/yHD/njcAAADuv2r/nz//oTj/p064oGf/zHAAAAA9Nir/tFIAAAD/tlTiuWf/tkIAAACynXEAAAAAAAAtIRW7zBpBAAAAM3RSTlMAABR1m7RXO8Ln31Z36zT+neXe5OzooRDfn+TZ4p3h2hTf4t3k3ucyrN1K5+Xaks52Sfs9CXgrAAAAjklEQVR42o3PbQ+CIBQFYEwboPhSYgoYunIqqLn6/z8uYdH8Vmdnu9vz4WwXgN/xTPRD2+sgOcZjsge/whXZgUaYYvT8QnuJaUrjrHUQreGczuEafQCO/SJTufTbroWsPgsllVhq3wJEk2jUSzX3CUEDJC84707djRc5MTAQxoLgupWRwW6UB5fS++NV8AbOZgnsC7BpEAAAAABJRU5ErkJggg==\");background-position: 2px center;}.ace_gutter-cell.ace_info {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAAAAAA6mKC9AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAAJ0Uk5TAAB2k804AAAAPklEQVQY02NgIB68QuO3tiLznjAwpKTgNyDbMegwisCHZUETUZV0ZqOquBpXj2rtnpSJT1AEnnRmL2OgGgAAIKkRQap2htgAAAAASUVORK5CYII=\");background-position: 2px center;}.ace_dark .ace_gutter-cell.ace_info {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAJFBMVEUAAAChoaGAgIAqKiq+vr6tra1ZWVmUlJSbm5s8PDxubm56enrdgzg3AAAAAXRSTlMAQObYZgAAAClJREFUeNpjYMAPdsMYHegyJZFQBlsUlMFVCWUYKkAZMxZAGdxlDMQBAG+TBP4B6RyJAAAAAElFTkSuQmCC\");}.ace_scrollbar {position: absolute;right: 0;bottom: 0;z-index: 6;}.ace_scrollbar-inner {position: absolute;cursor: text;left: 0;top: 0;}.ace_scrollbar-v{overflow-x: hidden;overflow-y: scroll;top: 0;}.ace_scrollbar-h {overflow-x: scroll;overflow-y: hidden;left: 0;}.ace_print-margin {position: absolute;height: 100%;}.ace_text-input {position: absolute;z-index: 0;width: 0.5em;height: 1em;opacity: 0;background: transparent;-moz-appearance: none;appearance: none;border: none;resize: none;outline: none;overflow: hidden;font: inherit;padding: 0 1px;margin: 0 -1px;text-indent: -1em;-ms-user-select: text;-moz-user-select: text;-webkit-user-select: text;user-select: text;white-space: pre!important;}.ace_text-input.ace_composition {background: inherit;color: inherit;z-index: 1000;opacity: 1;text-indent: 0;}.ace_layer {z-index: 1;position: absolute;overflow: hidden;word-wrap: normal;white-space: pre;height: 100%;width: 100%;-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;pointer-events: none;}.ace_gutter-layer {position: relative;width: auto;text-align: right;pointer-events: auto;}.ace_text-layer {font: inherit !important;}.ace_cjk {display: inline-block;text-align: center;}.ace_cursor-layer {z-index: 4;}.ace_cursor {z-index: 4;position: absolute;-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;border-left: 2px solid;transform: translatez(0);}.ace_slim-cursors .ace_cursor {border-left-width: 1px;}.ace_overwrite-cursors .ace_cursor {border-left-width: 0;border-bottom: 1px solid;}.ace_hidden-cursors .ace_cursor {opacity: 0.2;}.ace_smooth-blinking .ace_cursor {-webkit-transition: opacity 0.18s;transition: opacity 0.18s;}.ace_editor.ace_multiselect .ace_cursor {border-left-width: 1px;}.ace_marker-layer .ace_step, .ace_marker-layer .ace_stack {position: absolute;z-index: 3;}.ace_marker-layer .ace_selection {position: absolute;z-index: 5;}.ace_marker-layer .ace_bracket {position: absolute;z-index: 6;}.ace_marker-layer .ace_active-line {position: absolute;z-index: 2;}.ace_marker-layer .ace_selected-word {position: absolute;z-index: 4;-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;}.ace_line .ace_fold {-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;display: inline-block;height: 11px;margin-top: -2px;vertical-align: middle;background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII=\"),url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=\");background-repeat: no-repeat, repeat-x;background-position: center center, top left;color: transparent;border: 1px solid black;border-radius: 2px;cursor: pointer;pointer-events: auto;}.ace_dark .ace_fold {}.ace_fold:hover{background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII=\"),url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACBJREFUeNpi+P//fz4TAwPDZxDxD5X4i5fLMEwJgAADAEPVDbjNw87ZAAAAAElFTkSuQmCC\");}.ace_tooltip {background-color: #FFF;background-image: -webkit-linear-gradient(top, transparent, rgba(0, 0, 0, 0.1));background-image: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));border: 1px solid gray;border-radius: 1px;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);color: black;max-width: 100%;padding: 3px 4px;position: fixed;z-index: 999999;-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;cursor: default;white-space: pre;word-wrap: break-word;line-height: normal;font-style: normal;font-weight: normal;letter-spacing: normal;pointer-events: none;}.ace_folding-enabled > .ace_gutter-cell {padding-right: 13px;}.ace_fold-widget {-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;margin: 0 -12px 0 1px;display: none;width: 11px;vertical-align: top;background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAANElEQVR42mWKsQ0AMAzC8ixLlrzQjzmBiEjp0A6WwBCSPgKAXoLkqSot7nN3yMwR7pZ32NzpKkVoDBUxKAAAAABJRU5ErkJggg==\");background-repeat: no-repeat;background-position: center;border-radius: 3px;border: 1px solid transparent;cursor: pointer;}.ace_folding-enabled .ace_fold-widget {display: inline-block;   }.ace_fold-widget.ace_end {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAANElEQVR42m3HwQkAMAhD0YzsRchFKI7sAikeWkrxwScEB0nh5e7KTPWimZki4tYfVbX+MNl4pyZXejUO1QAAAABJRU5ErkJggg==\");}.ace_fold-widget.ace_closed {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAGCAYAAAAG5SQMAAAAOUlEQVR42jXKwQkAMAgDwKwqKD4EwQ26sSOkVWjgIIHAzPiCgaqiqnJHZnKICBERHN194O5b9vbLuAVRL+l0YWnZAAAAAElFTkSuQmCCXA==\");}.ace_fold-widget:hover {border: 1px solid rgba(0, 0, 0, 0.3);background-color: rgba(255, 255, 255, 0.2);box-shadow: 0 1px 1px rgba(255, 255, 255, 0.7);}.ace_fold-widget:active {border: 1px solid rgba(0, 0, 0, 0.4);background-color: rgba(0, 0, 0, 0.05);box-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);}.ace_dark .ace_fold-widget {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHklEQVQIW2P4//8/AzoGEQ7oGCaLLAhWiSwB146BAQCSTPYocqT0AAAAAElFTkSuQmCC\");}.ace_dark .ace_fold-widget.ace_end {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAH0lEQVQIW2P4//8/AxQ7wNjIAjDMgC4AxjCVKBirIAAF0kz2rlhxpAAAAABJRU5ErkJggg==\");}.ace_dark .ace_fold-widget.ace_closed {background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAHElEQVQIW2P4//+/AxAzgDADlOOAznHAKgPWAwARji8UIDTfQQAAAABJRU5ErkJggg==\");}.ace_dark .ace_fold-widget:hover {box-shadow: 0 1px 1px rgba(255, 255, 255, 0.2);background-color: rgba(255, 255, 255, 0.1);}.ace_dark .ace_fold-widget:active {box-shadow: 0 1px 1px rgba(255, 255, 255, 0.2);}.ace_fold-widget.ace_invalid {background-color: #FFB4B4;border-color: #DE5555;}.ace_fade-fold-widgets .ace_fold-widget {-webkit-transition: opacity 0.4s ease 0.05s;transition: opacity 0.4s ease 0.05s;opacity: 0;}.ace_fade-fold-widgets:hover .ace_fold-widget {-webkit-transition: opacity 0.05s ease 0.05s;transition: opacity 0.05s ease 0.05s;opacity:1;}.ace_underline {text-decoration: underline;}.ace_bold {font-weight: bold;}.ace_nobold .ace_bold {font-weight: normal;}.ace_italic {font-style: italic;}.ace_error-marker {background-color: rgba(255, 0, 0,0.2);position: absolute;z-index: 9;}.ace_highlight-marker {background-color: rgba(255, 255, 0,0.2);position: absolute;z-index: 8;}.ace_br1 {border-top-left-radius    : 3px;}.ace_br2 {border-top-right-radius   : 3px;}.ace_br3 {border-top-left-radius    : 3px; border-top-right-radius:    3px;}.ace_br4 {border-bottom-right-radius: 3px;}.ace_br5 {border-top-left-radius    : 3px; border-bottom-right-radius: 3px;}.ace_br6 {border-top-right-radius   : 3px; border-bottom-right-radius: 3px;}.ace_br7 {border-top-left-radius    : 3px; border-top-right-radius:    3px; border-bottom-right-radius: 3px;}.ace_br8 {border-bottom-left-radius : 3px;}.ace_br9 {border-top-left-radius    : 3px; border-bottom-left-radius:  3px;}.ace_br10{border-top-right-radius   : 3px; border-bottom-left-radius:  3px;}.ace_br11{border-top-left-radius    : 3px; border-top-right-radius:    3px; border-bottom-left-radius:  3px;}.ace_br12{border-bottom-right-radius: 3px; border-bottom-left-radius:  3px;}.ace_br13{border-top-left-radius    : 3px; border-bottom-right-radius: 3px; border-bottom-left-radius:  3px;}.ace_br14{border-top-right-radius   : 3px; border-bottom-right-radius: 3px; border-bottom-left-radius:  3px;}.ace_br15{border-top-left-radius    : 3px; border-top-right-radius:    3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px;}';i.importCssString(m,\"ace_editor.css\");var g=function(e,t){var n=this;this.container=e||i.createElement(\"div\"),this.$keepTextAreaAtCursor=!o.isOldIE,i.addCssClass(this.container,\"ace_editor\"),this.setTheme(t),this.$gutter=i.createElement(\"div\"),this.$gutter.className=\"ace_gutter\",this.container.appendChild(this.$gutter),this.scroller=i.createElement(\"div\"),this.scroller.className=\"ace_scroller\",this.container.appendChild(this.scroller),this.content=i.createElement(\"div\"),this.content.className=\"ace_content\",this.scroller.appendChild(this.content),this.$gutterLayer=new u(this.$gutter),this.$gutterLayer.on(\"changeGutterWidth\",this.onGutterResize.bind(this)),this.$markerBack=new a(this.content);var r=this.$textLayer=new f(this.content);this.canvas=r.element,this.$markerFront=new a(this.content),this.$cursorLayer=new l(this.content),this.$horizScroll=!1,this.$vScroll=!1,this.scrollBar=this.scrollBarV=new h(this.container,this),this.scrollBarH=new c(this.container,this),this.scrollBarV.addEventListener(\"scroll\",function(e){n.$scrollAnimation||n.session.setScrollTop(e.data-n.scrollMargin.top)}),this.scrollBarH.addEventListener(\"scroll\",function(e){n.$scrollAnimation||n.session.setScrollLeft(e.data-n.scrollMargin.left)}),this.scrollTop=0,this.scrollLeft=0,this.cursorPos={row:0,column:0},this.$fontMetrics=new d(this.container,500),this.$textLayer.$setFontMetrics(this.$fontMetrics),this.$textLayer.addEventListener(\"changeCharacterSize\",function(e){n.updateCharacterSize(),n.onResize(!0,n.gutterWidth,n.$size.width,n.$size.height),n._signal(\"changeCharacterSize\",e)}),this.$size={width:0,height:0,scrollerHeight:0,scrollerWidth:0,$dirty:!0},this.layerConfig={width:1,padding:0,firstRow:0,firstRowScreen:0,lastRow:0,lineHeight:0,characterWidth:0,minHeight:1,maxHeight:1,offset:0,height:1,gutterOffset:1},this.scrollMargin={left:0,right:0,top:0,bottom:0,v:0,h:0},this.$loop=new p(this.$renderChanges.bind(this),this.container.ownerDocument.defaultView),this.$loop.schedule(this.CHANGE_FULL),this.updateCharacterSize(),this.setPadding(4),s.resetOptions(this),s._emit(\"renderer\",this)};(function(){this.CHANGE_CURSOR=1,this.CHANGE_MARKER=2,this.CHANGE_GUTTER=4,this.CHANGE_SCROLL=8,this.CHANGE_LINES=16,this.CHANGE_TEXT=32,this.CHANGE_SIZE=64,this.CHANGE_MARKER_BACK=128,this.CHANGE_MARKER_FRONT=256,this.CHANGE_FULL=512,this.CHANGE_H_SCROLL=1024,r.implement(this,v),this.updateCharacterSize=function(){this.$textLayer.allowBoldFonts!=this.$allowBoldFonts&&(this.$allowBoldFonts=this.$textLayer.allowBoldFonts,this.setStyle(\"ace_nobold\",!this.$allowBoldFonts)),this.layerConfig.characterWidth=this.characterWidth=this.$textLayer.getCharacterWidth(),this.layerConfig.lineHeight=this.lineHeight=this.$textLayer.getLineHeight(),this.$updatePrintMargin()},this.setSession=function(e){this.session&&this.session.doc.off(\"changeNewLineMode\",this.onChangeNewLineMode),this.session=e,e&&this.scrollMargin.top&&e.getScrollTop()<=0&&e.setScrollTop(-this.scrollMargin.top),this.$cursorLayer.setSession(e),this.$markerBack.setSession(e),this.$markerFront.setSession(e),this.$gutterLayer.setSession(e),this.$textLayer.setSession(e);if(!e)return;this.$loop.schedule(this.CHANGE_FULL),this.session.$setFontMetrics(this.$fontMetrics),this.onChangeNewLineMode=this.onChangeNewLineMode.bind(this),this.onChangeNewLineMode(),this.session.doc.on(\"changeNewLineMode\",this.onChangeNewLineMode)},this.updateLines=function(e,t,n){t===undefined&&(t=Infinity),this.$changedLines?(this.$changedLines.firstRow>e&&(this.$changedLines.firstRow=e),this.$changedLines.lastRow<t&&(this.$changedLines.lastRow=t)):this.$changedLines={firstRow:e,lastRow:t};if(this.$changedLines.lastRow<this.layerConfig.firstRow){if(!n)return;this.$changedLines.lastRow=this.layerConfig.lastRow}if(this.$changedLines.firstRow>this.layerConfig.lastRow)return;this.$loop.schedule(this.CHANGE_LINES)},this.onChangeNewLineMode=function(){this.$loop.schedule(this.CHANGE_TEXT),this.$textLayer.$updateEolChar()},this.onChangeTabSize=function(){this.$loop.schedule(this.CHANGE_TEXT|this.CHANGE_MARKER),this.$textLayer.onChangeTabSize()},this.updateText=function(){this.$loop.schedule(this.CHANGE_TEXT)},this.updateFull=function(e){e?this.$renderChanges(this.CHANGE_FULL,!0):this.$loop.schedule(this.CHANGE_FULL)},this.updateFontSize=function(){this.$textLayer.checkForSizeChanges()},this.$changes=0,this.$updateSizeAsync=function(){this.$loop.pending?this.$size.$dirty=!0:this.onResize()},this.onResize=function(e,t,n,r){if(this.resizing>2)return;this.resizing>0?this.resizing++:this.resizing=e?1:0;var i=this.container;r||(r=i.clientHeight||i.scrollHeight),n||(n=i.clientWidth||i.scrollWidth);var s=this.$updateCachedSize(e,t,n,r);if(!this.$size.scrollerHeight||!n&&!r)return this.resizing=0;e&&(this.$gutterLayer.$padding=null),e?this.$renderChanges(s|this.$changes,!0):this.$loop.schedule(s|this.$changes),this.resizing&&(this.resizing=0),this.scrollBarV.scrollLeft=this.scrollBarV.scrollTop=null},this.$updateCachedSize=function(e,t,n,r){r-=this.$extraHeight||0;var i=0,s=this.$size,o={width:s.width,height:s.height,scrollerHeight:s.scrollerHeight,scrollerWidth:s.scrollerWidth};r&&(e||s.height!=r)&&(s.height=r,i|=this.CHANGE_SIZE,s.scrollerHeight=s.height,this.$horizScroll&&(s.scrollerHeight-=this.scrollBarH.getHeight()),this.scrollBarV.element.style.bottom=this.scrollBarH.getHeight()+\"px\",i|=this.CHANGE_SCROLL);if(n&&(e||s.width!=n)){i|=this.CHANGE_SIZE,s.width=n,t==null&&(t=this.$showGutter?this.$gutter.offsetWidth:0),this.gutterWidth=t,this.scrollBarH.element.style.left=this.scroller.style.left=t+\"px\",s.scrollerWidth=Math.max(0,n-t-this.scrollBarV.getWidth()),this.scrollBarH.element.style.right=this.scroller.style.right=this.scrollBarV.getWidth()+\"px\",this.scroller.style.bottom=this.scrollBarH.getHeight()+\"px\";if(this.session&&this.session.getUseWrapMode()&&this.adjustWrapLimit()||e)i|=this.CHANGE_FULL}return s.$dirty=!n||!r,i&&this._signal(\"resize\",o),i},this.onGutterResize=function(){var e=this.$showGutter?this.$gutter.offsetWidth:0;e!=this.gutterWidth&&(this.$changes|=this.$updateCachedSize(!0,e,this.$size.width,this.$size.height)),this.session.getUseWrapMode()&&this.adjustWrapLimit()?this.$loop.schedule(this.CHANGE_FULL):this.$size.$dirty?this.$loop.schedule(this.CHANGE_FULL):(this.$computeLayerConfig(),this.$loop.schedule(this.CHANGE_MARKER))},this.adjustWrapLimit=function(){var e=this.$size.scrollerWidth-this.$padding*2,t=Math.floor(e/this.characterWidth);return this.session.adjustWrapLimit(t,this.$showPrintMargin&&this.$printMarginColumn)},this.setAnimatedScroll=function(e){this.setOption(\"animatedScroll\",e)},this.getAnimatedScroll=function(){return this.$animatedScroll},this.setShowInvisibles=function(e){this.setOption(\"showInvisibles\",e)},this.getShowInvisibles=function(){return this.getOption(\"showInvisibles\")},this.getDisplayIndentGuides=function(){return this.getOption(\"displayIndentGuides\")},this.setDisplayIndentGuides=function(e){this.setOption(\"displayIndentGuides\",e)},this.setShowPrintMargin=function(e){this.setOption(\"showPrintMargin\",e)},this.getShowPrintMargin=function(){return this.getOption(\"showPrintMargin\")},this.setPrintMarginColumn=function(e){this.setOption(\"printMarginColumn\",e)},this.getPrintMarginColumn=function(){return this.getOption(\"printMarginColumn\")},this.getShowGutter=function(){return this.getOption(\"showGutter\")},this.setShowGutter=function(e){return this.setOption(\"showGutter\",e)},this.getFadeFoldWidgets=function(){return this.getOption(\"fadeFoldWidgets\")},this.setFadeFoldWidgets=function(e){this.setOption(\"fadeFoldWidgets\",e)},this.setHighlightGutterLine=function(e){this.setOption(\"highlightGutterLine\",e)},this.getHighlightGutterLine=function(){return this.getOption(\"highlightGutterLine\")},this.$updateGutterLineHighlight=function(){var e=this.$cursorLayer.$pixelPos,t=this.layerConfig.lineHeight;if(this.session.getUseWrapMode()){var n=this.session.selection.getCursor();n.column=0,e=this.$cursorLayer.getPixelPosition(n,!0),t*=this.session.getRowLength(n.row)}this.$gutterLineHighlight.style.top=e.top-this.layerConfig.offset+\"px\",this.$gutterLineHighlight.style.height=t+\"px\"},this.$updatePrintMargin=function(){if(!this.$showPrintMargin&&!this.$printMarginEl)return;if(!this.$printMarginEl){var e=i.createElement(\"div\");e.className=\"ace_layer ace_print-margin-layer\",this.$printMarginEl=i.createElement(\"div\"),this.$printMarginEl.className=\"ace_print-margin\",e.appendChild(this.$printMarginEl),this.content.insertBefore(e,this.content.firstChild)}var t=this.$printMarginEl.style;t.left=this.characterWidth*this.$printMarginColumn+this.$padding+\"px\",t.visibility=this.$showPrintMargin?\"visible\":\"hidden\",this.session&&this.session.$wrap==-1&&this.adjustWrapLimit()},this.getContainerElement=function(){return this.container},this.getMouseEventTarget=function(){return this.scroller},this.getTextAreaContainer=function(){return this.container},this.$moveTextAreaToCursor=function(){if(!this.$keepTextAreaAtCursor)return;var e=this.layerConfig,t=this.$cursorLayer.$pixelPos.top,n=this.$cursorLayer.$pixelPos.left;t-=e.offset;var r=this.textarea.style,i=this.lineHeight;if(t<0||t>e.height-i){r.top=r.left=\"0\";return}var s=this.characterWidth;if(this.$composition){var o=this.textarea.value.replace(/^\\x01+/,\"\");s*=this.session.$getStringScreenWidth(o)[0]+2,i+=2}n-=this.scrollLeft,n>this.$size.scrollerWidth-s&&(n=this.$size.scrollerWidth-s),n+=this.gutterWidth,r.height=i+\"px\",r.width=s+\"px\",r.left=Math.min(n,this.$size.scrollerWidth-s)+\"px\",r.top=Math.min(t,this.$size.height-i)+\"px\"},this.getFirstVisibleRow=function(){return this.layerConfig.firstRow},this.getFirstFullyVisibleRow=function(){return this.layerConfig.firstRow+(this.layerConfig.offset===0?0:1)},this.getLastFullyVisibleRow=function(){var e=Math.floor((this.layerConfig.height+this.layerConfig.offset)/this.layerConfig.lineHeight);return this.layerConfig.firstRow-1+e},this.getLastVisibleRow=function(){return this.layerConfig.lastRow},this.$padding=null,this.setPadding=function(e){this.$padding=e,this.$textLayer.setPadding(e),this.$cursorLayer.setPadding(e),this.$markerFront.setPadding(e),this.$markerBack.setPadding(e),this.$loop.schedule(this.CHANGE_FULL),this.$updatePrintMargin()},this.setScrollMargin=function(e,t,n,r){var i=this.scrollMargin;i.top=e|0,i.bottom=t|0,i.right=r|0,i.left=n|0,i.v=i.top+i.bottom,i.h=i.left+i.right,i.top&&this.scrollTop<=0&&this.session&&this.session.setScrollTop(-i.top),this.updateFull()},this.getHScrollBarAlwaysVisible=function(){return this.$hScrollBarAlwaysVisible},this.setHScrollBarAlwaysVisible=function(e){this.setOption(\"hScrollBarAlwaysVisible\",e)},this.getVScrollBarAlwaysVisible=function(){return this.$vScrollBarAlwaysVisible},this.setVScrollBarAlwaysVisible=function(e){this.setOption(\"vScrollBarAlwaysVisible\",e)},this.$updateScrollBarV=function(){var e=this.layerConfig.maxHeight,t=this.$size.scrollerHeight;!this.$maxLines&&this.$scrollPastEnd&&(e-=(t-this.lineHeight)*this.$scrollPastEnd,this.scrollTop>e-t&&(e=this.scrollTop+t,this.scrollBarV.scrollTop=null)),this.scrollBarV.setScrollHeight(e+this.scrollMargin.v),this.scrollBarV.setScrollTop(this.scrollTop+this.scrollMargin.top)},this.$updateScrollBarH=function(){this.scrollBarH.setScrollWidth(this.layerConfig.width+2*this.$padding+this.scrollMargin.h),this.scrollBarH.setScrollLeft(this.scrollLeft+this.scrollMargin.left)},this.$frozen=!1,this.freeze=function(){this.$frozen=!0},this.unfreeze=function(){this.$frozen=!1},this.$renderChanges=function(e,t){this.$changes&&(e|=this.$changes,this.$changes=0);if(!this.session||!this.container.offsetWidth||this.$frozen||!e&&!t){this.$changes|=e;return}if(this.$size.$dirty)return this.$changes|=e,this.onResize(!0);this.lineHeight||this.$textLayer.checkForSizeChanges(),this._signal(\"beforeRender\");var n=this.layerConfig;if(e&this.CHANGE_FULL||e&this.CHANGE_SIZE||e&this.CHANGE_TEXT||e&this.CHANGE_LINES||e&this.CHANGE_SCROLL||e&this.CHANGE_H_SCROLL){e|=this.$computeLayerConfig();if(n.firstRow!=this.layerConfig.firstRow&&n.firstRowScreen==this.layerConfig.firstRowScreen){var r=this.scrollTop+(n.firstRow-this.layerConfig.firstRow)*this.lineHeight;r>0&&(this.scrollTop=r,e|=this.CHANGE_SCROLL,e|=this.$computeLayerConfig())}n=this.layerConfig,this.$updateScrollBarV(),e&this.CHANGE_H_SCROLL&&this.$updateScrollBarH(),this.$gutterLayer.element.style.marginTop=-n.offset+\"px\",this.content.style.marginTop=-n.offset+\"px\",this.content.style.width=n.width+2*this.$padding+\"px\",this.content.style.height=n.minHeight+\"px\"}e&this.CHANGE_H_SCROLL&&(this.content.style.marginLeft=-this.scrollLeft+\"px\",this.scroller.className=this.scrollLeft<=0?\"ace_scroller\":\"ace_scroller ace_scroll-left\");if(e&this.CHANGE_FULL){this.$textLayer.update(n),this.$showGutter&&this.$gutterLayer.update(n),this.$markerBack.update(n),this.$markerFront.update(n),this.$cursorLayer.update(n),this.$moveTextAreaToCursor(),this.$highlightGutterLine&&this.$updateGutterLineHighlight(),this._signal(\"afterRender\");return}if(e&this.CHANGE_SCROLL){e&this.CHANGE_TEXT||e&this.CHANGE_LINES?this.$textLayer.update(n):this.$textLayer.scrollLines(n),this.$showGutter&&this.$gutterLayer.update(n),this.$markerBack.update(n),this.$markerFront.update(n),this.$cursorLayer.update(n),this.$highlightGutterLine&&this.$updateGutterLineHighlight(),this.$moveTextAreaToCursor(),this._signal(\"afterRender\");return}e&this.CHANGE_TEXT?(this.$textLayer.update(n),this.$showGutter&&this.$gutterLayer.update(n)):e&this.CHANGE_LINES?(this.$updateLines()||e&this.CHANGE_GUTTER&&this.$showGutter)&&this.$gutterLayer.update(n):(e&this.CHANGE_TEXT||e&this.CHANGE_GUTTER)&&this.$showGutter&&this.$gutterLayer.update(n),e&this.CHANGE_CURSOR&&(this.$cursorLayer.update(n),this.$moveTextAreaToCursor(),this.$highlightGutterLine&&this.$updateGutterLineHighlight()),e&(this.CHANGE_MARKER|this.CHANGE_MARKER_FRONT)&&this.$markerFront.update(n),e&(this.CHANGE_MARKER|this.CHANGE_MARKER_BACK)&&this.$markerBack.update(n),this._signal(\"afterRender\")},this.$autosize=function(){var e=this.session.getScreenLength()*this.lineHeight,t=this.$maxLines*this.lineHeight,n=Math.max((this.$minLines||1)*this.lineHeight,Math.min(t,e))+this.scrollMargin.v+(this.$extraHeight||0);this.$horizScroll&&(n+=this.scrollBarH.getHeight());var r=e>t;if(n!=this.desiredHeight||this.$size.height!=this.desiredHeight||r!=this.$vScroll){r!=this.$vScroll&&(this.$vScroll=r,this.scrollBarV.setVisible(r));var i=this.container.clientWidth;this.container.style.height=n+\"px\",this.$updateCachedSize(!0,this.$gutterWidth,i,n),this.desiredHeight=n,this._signal(\"autosize\")}},this.$computeLayerConfig=function(){var e=this.session,t=this.$size,n=t.height<=2*this.lineHeight,r=this.session.getScreenLength(),i=r*this.lineHeight,s=this.$getLongestLine(),o=!n&&(this.$hScrollBarAlwaysVisible||t.scrollerWidth-s-2*this.$padding<0),u=this.$horizScroll!==o;u&&(this.$horizScroll=o,this.scrollBarH.setVisible(o));var a=this.$vScroll;this.$maxLines&&this.lineHeight>1&&this.$autosize();var f=this.scrollTop%this.lineHeight,l=t.scrollerHeight+this.lineHeight,c=!this.$maxLines&&this.$scrollPastEnd?(t.scrollerHeight-this.lineHeight)*this.$scrollPastEnd:0;i+=c;var h=this.scrollMargin;this.session.setScrollTop(Math.max(-h.top,Math.min(this.scrollTop,i-t.scrollerHeight+h.bottom))),this.session.setScrollLeft(Math.max(-h.left,Math.min(this.scrollLeft,s+2*this.$padding-t.scrollerWidth+h.right)));var p=!n&&(this.$vScrollBarAlwaysVisible||t.scrollerHeight-i+c<0||this.scrollTop>h.top),d=a!==p;d&&(this.$vScroll=p,this.scrollBarV.setVisible(p));var v=Math.ceil(l/this.lineHeight)-1,m=Math.max(0,Math.round((this.scrollTop-f)/this.lineHeight)),g=m+v,y,b,w=this.lineHeight;m=e.screenToDocumentRow(m,0);var E=e.getFoldLine(m);E&&(m=E.start.row),y=e.documentToScreenRow(m,0),b=e.getRowLength(m)*w,g=Math.min(e.screenToDocumentRow(g,0),e.getLength()-1),l=t.scrollerHeight+e.getRowLength(g)*w+b,f=this.scrollTop-y*w;var S=0;this.layerConfig.width!=s&&(S=this.CHANGE_H_SCROLL);if(u||d)S=this.$updateCachedSize(!0,this.gutterWidth,t.width,t.height),this._signal(\"scrollbarVisibilityChanged\"),d&&(s=this.$getLongestLine());return this.layerConfig={width:s,padding:this.$padding,firstRow:m,firstRowScreen:y,lastRow:g,lineHeight:w,characterWidth:this.characterWidth,minHeight:l,maxHeight:i,offset:f,gutterOffset:Math.max(0,Math.ceil((f+t.height-t.scrollerHeight)/w)),height:this.$size.scrollerHeight},S},this.$updateLines=function(){var e=this.$changedLines.firstRow,t=this.$changedLines.lastRow;this.$changedLines=null;var n=this.layerConfig;if(e>n.lastRow+1)return;if(t<n.firstRow)return;if(t===Infinity){this.$showGutter&&this.$gutterLayer.update(n),this.$textLayer.update(n);return}return this.$textLayer.updateLines(n,e,t),!0},this.$getLongestLine=function(){var e=this.session.getScreenWidth();return this.showInvisibles&&!this.session.$useWrapMode&&(e+=1),Math.max(this.$size.scrollerWidth-2*this.$padding,Math.round(e*this.characterWidth))},this.updateFrontMarkers=function(){this.$markerFront.setMarkers(this.session.getMarkers(!0)),this.$loop.schedule(this.CHANGE_MARKER_FRONT)},this.updateBackMarkers=function(){this.$markerBack.setMarkers(this.session.getMarkers()),this.$loop.schedule(this.CHANGE_MARKER_BACK)},this.addGutterDecoration=function(e,t){this.$gutterLayer.addGutterDecoration(e,t)},this.removeGutterDecoration=function(e,t){this.$gutterLayer.removeGutterDecoration(e,t)},this.updateBreakpoints=function(e){this.$loop.schedule(this.CHANGE_GUTTER)},this.setAnnotations=function(e){this.$gutterLayer.setAnnotations(e),this.$loop.schedule(this.CHANGE_GUTTER)},this.updateCursor=function(){this.$loop.schedule(this.CHANGE_CURSOR)},this.hideCursor=function(){this.$cursorLayer.hideCursor()},this.showCursor=function(){this.$cursorLayer.showCursor()},this.scrollSelectionIntoView=function(e,t,n){this.scrollCursorIntoView(e,n),this.scrollCursorIntoView(t,n)},this.scrollCursorIntoView=function(e,t,n){if(this.$size.scrollerHeight===0)return;var r=this.$cursorLayer.getPixelPosition(e),i=r.left,s=r.top,o=n&&n.top||0,u=n&&n.bottom||0,a=this.$scrollAnimation?this.session.getScrollTop():this.scrollTop;a+o>s?(t&&(s-=t*this.$size.scrollerHeight),s===0&&(s=-this.scrollMargin.top),this.session.setScrollTop(s)):a+this.$size.scrollerHeight-u<s+this.lineHeight&&(t&&(s+=t*this.$size.scrollerHeight),this.session.setScrollTop(s+this.lineHeight-this.$size.scrollerHeight));var f=this.scrollLeft;f>i?(i<this.$padding+2*this.layerConfig.characterWidth&&(i=-this.scrollMargin.left),this.session.setScrollLeft(i)):f+this.$size.scrollerWidth<i+this.characterWidth?this.session.setScrollLeft(Math.round(i+this.characterWidth-this.$size.scrollerWidth)):f<=this.$padding&&i-f<this.characterWidth&&this.session.setScrollLeft(0)},this.getScrollTop=function(){return this.session.getScrollTop()},this.getScrollLeft=function(){return this.session.getScrollLeft()},this.getScrollTopRow=function(){return this.scrollTop/this.lineHeight},this.getScrollBottomRow=function(){return Math.max(0,Math.floor((this.scrollTop+this.$size.scrollerHeight)/this.lineHeight)-1)},this.scrollToRow=function(e){this.session.setScrollTop(e*this.lineHeight)},this.alignCursor=function(e,t){typeof e==\"number\"&&(e={row:e,column:0});var n=this.$cursorLayer.getPixelPosition(e),r=this.$size.scrollerHeight-this.lineHeight,i=n.top-r*(t||0);return this.session.setScrollTop(i),i},this.STEPS=8,this.$calcSteps=function(e,t){var n=0,r=this.STEPS,i=[],s=function(e,t,n){return n*(Math.pow(e-1,3)+1)+t};for(n=0;n<r;++n)i.push(s(n/this.STEPS,e,t-e));return i},this.scrollToLine=function(e,t,n,r){var i=this.$cursorLayer.getPixelPosition({row:e,column:0}),s=i.top;t&&(s-=this.$size.scrollerHeight/2);var o=this.scrollTop;this.session.setScrollTop(s),n!==!1&&this.animateScrolling(o,r)},this.animateScrolling=function(e,t){var n=this.scrollTop;if(!this.$animatedScroll)return;var r=this;if(e==n)return;if(this.$scrollAnimation){var i=this.$scrollAnimation.steps;if(i.length){e=i[0];if(e==n)return}}var s=r.$calcSteps(e,n);this.$scrollAnimation={from:e,to:n,steps:s},clearInterval(this.$timer),r.session.setScrollTop(s.shift()),r.session.$scrollTop=n,this.$timer=setInterval(function(){s.length?(r.session.setScrollTop(s.shift()),r.session.$scrollTop=n):n!=null?(r.session.$scrollTop=-1,r.session.setScrollTop(n),n=null):(r.$timer=clearInterval(r.$timer),r.$scrollAnimation=null,t&&t())},10)},this.scrollToY=function(e){this.scrollTop!==e&&(this.$loop.schedule(this.CHANGE_SCROLL),this.scrollTop=e)},this.scrollToX=function(e){this.scrollLeft!==e&&(this.scrollLeft=e),this.$loop.schedule(this.CHANGE_H_SCROLL)},this.scrollTo=function(e,t){this.session.setScrollTop(t),this.session.setScrollLeft(t)},this.scrollBy=function(e,t){t&&this.session.setScrollTop(this.session.getScrollTop()+t),e&&this.session.setScrollLeft(this.session.getScrollLeft()+e)},this.isScrollableBy=function(e,t){if(t<0&&this.session.getScrollTop()>=1-this.scrollMargin.top)return!0;if(t>0&&this.session.getScrollTop()+this.$size.scrollerHeight-this.layerConfig.maxHeight<-1+this.scrollMargin.bottom)return!0;if(e<0&&this.session.getScrollLeft()>=1-this.scrollMargin.left)return!0;if(e>0&&this.session.getScrollLeft()+this.$size.scrollerWidth-this.layerConfig.width<-1+this.scrollMargin.right)return!0},this.pixelToScreenCoordinates=function(e,t){var n=this.scroller.getBoundingClientRect(),r=(e+this.scrollLeft-n.left-this.$padding)/this.characterWidth,i=Math.floor((t+this.scrollTop-n.top)/this.lineHeight),s=Math.round(r);return{row:i,column:s,side:r-s>0?1:-1}},this.screenToTextCoordinates=function(e,t){var n=this.scroller.getBoundingClientRect(),r=Math.round((e+this.scrollLeft-n.left-this.$padding)/this.characterWidth),i=(t+this.scrollTop-n.top)/this.lineHeight;return this.session.screenToDocumentPosition(i,Math.max(r,0))},this.textToScreenCoordinates=function(e,t){var n=this.scroller.getBoundingClientRect(),r=this.session.documentToScreenPosition(e,t),i=this.$padding+Math.round(r.column*this.characterWidth),s=r.row*this.lineHeight;return{pageX:n.left+i-this.scrollLeft,pageY:n.top+s-this.scrollTop}},this.visualizeFocus=function(){i.addCssClass(this.container,\"ace_focus\")},this.visualizeBlur=function(){i.removeCssClass(this.container,\"ace_focus\")},this.showComposition=function(e){this.$composition||(this.$composition={keepTextAreaAtCursor:this.$keepTextAreaAtCursor,cssText:this.textarea.style.cssText}),this.$keepTextAreaAtCursor=!0,i.addCssClass(this.textarea,\"ace_composition\"),this.textarea.style.cssText=\"\",this.$moveTextAreaToCursor()},this.setCompositionText=function(e){this.$moveTextAreaToCursor()},this.hideComposition=function(){if(!this.$composition)return;i.removeCssClass(this.textarea,\"ace_composition\"),this.$keepTextAreaAtCursor=this.$composition.keepTextAreaAtCursor,this.textarea.style.cssText=this.$composition.cssText,this.$composition=null},this.setTheme=function(e,t){function o(r){if(n.$themeId!=e)return t&&t();if(!r.cssClass)return;i.importCssString(r.cssText,r.cssClass,n.container.ownerDocument),n.theme&&i.removeCssClass(n.container,n.theme.cssClass);var s=\"padding\"in r?r.padding:\"padding\"in(n.theme||{})?4:n.$padding;n.$padding&&s!=n.$padding&&n.setPadding(s),n.$theme=r.cssClass,n.theme=r,i.addCssClass(n.container,r.cssClass),i.setCssClass(n.container,\"ace_dark\",r.isDark),n.$size&&(n.$size.width=0,n.$updateSizeAsync()),n._dispatchEvent(\"themeLoaded\",{theme:r}),t&&t()}var n=this;this.$themeId=e,n._dispatchEvent(\"themeChange\",{theme:e});if(!e||typeof e==\"string\"){var r=e||this.$options.theme.initialValue;s.loadModule([\"theme\",r],o)}else o(e)},this.getTheme=function(){return this.$themeId},this.setStyle=function(e,t){i.setCssClass(this.container,e,t!==!1)},this.unsetStyle=function(e){i.removeCssClass(this.container,e)},this.setCursorStyle=function(e){this.scroller.style.cursor!=e&&(this.scroller.style.cursor=e)},this.setMouseCursor=function(e){this.scroller.style.cursor=e},this.destroy=function(){this.$textLayer.destroy(),this.$cursorLayer.destroy()}}).call(g.prototype),s.defineOptions(g.prototype,\"renderer\",{animatedScroll:{initialValue:!1},showInvisibles:{set:function(e){this.$textLayer.setShowInvisibles(e)&&this.$loop.schedule(this.CHANGE_TEXT)},initialValue:!1},showPrintMargin:{set:function(){this.$updatePrintMargin()},initialValue:!0},printMarginColumn:{set:function(){this.$updatePrintMargin()},initialValue:80},printMargin:{set:function(e){typeof e==\"number\"&&(this.$printMarginColumn=e),this.$showPrintMargin=!!e,this.$updatePrintMargin()},get:function(){return this.$showPrintMargin&&this.$printMarginColumn}},showGutter:{set:function(e){this.$gutter.style.display=e?\"block\":\"none\",this.$loop.schedule(this.CHANGE_FULL),this.onGutterResize()},initialValue:!0},fadeFoldWidgets:{set:function(e){i.setCssClass(this.$gutter,\"ace_fade-fold-widgets\",e)},initialValue:!1},showFoldWidgets:{set:function(e){this.$gutterLayer.setShowFoldWidgets(e)},initialValue:!0},showLineNumbers:{set:function(e){this.$gutterLayer.setShowLineNumbers(e),this.$loop.schedule(this.CHANGE_GUTTER)},initialValue:!0},displayIndentGuides:{set:function(e){this.$textLayer.setDisplayIndentGuides(e)&&this.$loop.schedule(this.CHANGE_TEXT)},initialValue:!0},highlightGutterLine:{set:function(e){if(!this.$gutterLineHighlight){this.$gutterLineHighlight=i.createElement(\"div\"),this.$gutterLineHighlight.className=\"ace_gutter-active-line\",this.$gutter.appendChild(this.$gutterLineHighlight);return}this.$gutterLineHighlight.style.display=e?\"\":\"none\",this.$cursorLayer.$pixelPos&&this.$updateGutterLineHighlight()},initialValue:!1,value:!0},hScrollBarAlwaysVisible:{set:function(e){(!this.$hScrollBarAlwaysVisible||!this.$horizScroll)&&this.$loop.schedule(this.CHANGE_SCROLL)},initialValue:!1},vScrollBarAlwaysVisible:{set:function(e){(!this.$vScrollBarAlwaysVisible||!this.$vScroll)&&this.$loop.schedule(this.CHANGE_SCROLL)},initialValue:!1},fontSize:{set:function(e){typeof e==\"number\"&&(e+=\"px\"),this.container.style.fontSize=e,this.updateFontSize()},initialValue:12},fontFamily:{set:function(e){this.container.style.fontFamily=e,this.updateFontSize()}},maxLines:{set:function(e){this.updateFull()}},minLines:{set:function(e){this.updateFull()}},scrollPastEnd:{set:function(e){e=+e||0;if(this.$scrollPastEnd==e)return;this.$scrollPastEnd=e,this.$loop.schedule(this.CHANGE_SCROLL)},initialValue:0,handlesSet:!0},fixedWidthGutter:{set:function(e){this.$gutterLayer.$fixedWidth=!!e,this.$loop.schedule(this.CHANGE_GUTTER)}},theme:{set:function(e){this.setTheme(e)},get:function(){return this.$themeId||this.theme},initialValue:\"./theme/textmate\",handlesSet:!0}}),t.VirtualRenderer=g}),define(\"ace/worker/worker_client\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/net\",\"ace/lib/event_emitter\",\"ace/config\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/net\"),s=e(\"../lib/event_emitter\").EventEmitter,o=e(\"../config\"),u=function(t,n,r,i){this.$sendDeltaQueue=this.$sendDeltaQueue.bind(this),this.changeListener=this.changeListener.bind(this),this.onMessage=this.onMessage.bind(this),e.nameToUrl&&!e.toUrl&&(e.toUrl=e.nameToUrl);if(o.get(\"packaged\")||!e.toUrl)i=i||o.moduleUrl(n,\"worker\");else{var s=this.$normalizePath;i=i||s(e.toUrl(\"ace/worker/worker.js\",null,\"_\"));var u={};t.forEach(function(t){u[t]=s(e.toUrl(t,null,\"_\").replace(/(\\.js)?(\\?.*)?$/,\"\"))})}try{this.$worker=new Worker(i)}catch(a){if(!(a instanceof window.DOMException))throw a;var f=this.$workerBlob(i),l=window.URL||window.webkitURL,c=l.createObjectURL(f);this.$worker=new Worker(c),l.revokeObjectURL(c)}this.$worker.postMessage({init:!0,tlns:u,module:n,classname:r}),this.callbackId=1,this.callbacks={},this.$worker.onmessage=this.onMessage};(function(){r.implement(this,s),this.onMessage=function(e){var t=e.data;switch(t.type){case\"event\":this._signal(t.name,{data:t.data});break;case\"call\":var n=this.callbacks[t.id];n&&(n(t.data),delete this.callbacks[t.id]);break;case\"error\":this.reportError(t.data);break;case\"log\":window.console&&console.log&&console.log.apply(console,t.data)}},this.reportError=function(e){window.console&&console.error&&console.error(e)},this.$normalizePath=function(e){return i.qualifyURL(e)},this.terminate=function(){this._signal(\"terminate\",{}),this.deltaQueue=null,this.$worker.terminate(),this.$worker=null,this.$doc&&this.$doc.off(\"change\",this.changeListener),this.$doc=null},this.send=function(e,t){this.$worker.postMessage({command:e,args:t})},this.call=function(e,t,n){if(n){var r=this.callbackId++;this.callbacks[r]=n,t.push(r)}this.send(e,t)},this.emit=function(e,t){try{this.$worker.postMessage({event:e,data:{data:t.data}})}catch(n){console.error(n.stack)}},this.attachToDocument=function(e){this.$doc&&this.terminate(),this.$doc=e,this.call(\"setValue\",[e.getValue()]),e.on(\"change\",this.changeListener)},this.changeListener=function(e){this.deltaQueue||(this.deltaQueue=[],setTimeout(this.$sendDeltaQueue,0)),e.action==\"insert\"?this.deltaQueue.push(e.start,e.lines):this.deltaQueue.push(e.start,e.end)},this.$sendDeltaQueue=function(){var e=this.deltaQueue;if(!e)return;this.deltaQueue=null,e.length>50&&e.length>this.$doc.getLength()>>1?this.call(\"setValue\",[this.$doc.getValue()]):this.emit(\"change\",{data:e})},this.$workerBlob=function(e){var t=\"importScripts('\"+i.qualifyURL(e)+\"');\";try{return new Blob([t],{type:\"application/javascript\"})}catch(n){var r=window.BlobBuilder||window.WebKitBlobBuilder||window.MozBlobBuilder,s=new r;return s.append(t),s.getBlob(\"application/javascript\")}}}).call(u.prototype);var a=function(e,t,n){this.$sendDeltaQueue=this.$sendDeltaQueue.bind(this),this.changeListener=this.changeListener.bind(this),this.callbackId=1,this.callbacks={},this.messageBuffer=[];var r=null,i=!1,u=Object.create(s),a=this;this.$worker={},this.$worker.terminate=function(){},this.$worker.postMessage=function(e){a.messageBuffer.push(e),r&&(i?setTimeout(f):f())},this.setEmitSync=function(e){i=e};var f=function(){var e=a.messageBuffer.shift();e.command?r[e.command].apply(r,e.args):e.event&&u._signal(e.event,e.data)};u.postMessage=function(e){a.onMessage({data:e})},u.callback=function(e,t){this.postMessage({type:\"call\",id:t,data:e})},u.emit=function(e,t){this.postMessage({type:\"event\",name:e,data:t})},o.loadModule([\"worker\",t],function(e){r=new e[n](u);while(a.messageBuffer.length)f()})};a.prototype=u.prototype,t.UIWorkerClient=a,t.WorkerClient=u}),define(\"ace/placeholder\",[\"require\",\"exports\",\"module\",\"ace/range\",\"ace/lib/event_emitter\",\"ace/lib/oop\"],function(e,t,n){\"use strict\";var r=e(\"./range\").Range,i=e(\"./lib/event_emitter\").EventEmitter,s=e(\"./lib/oop\"),o=function(e,t,n,r,i,s){var o=this;this.length=t,this.session=e,this.doc=e.getDocument(),this.mainClass=i,this.othersClass=s,this.$onUpdate=this.onUpdate.bind(this),this.doc.on(\"change\",this.$onUpdate),this.$others=r,this.$onCursorChange=function(){setTimeout(function(){o.onCursorChange()})},this.$pos=n;var u=e.getUndoManager().$undoStack||e.getUndoManager().$undostack||{length:-1};this.$undoStackDepth=u.length,this.setup(),e.selection.on(\"changeCursor\",this.$onCursorChange)};(function(){s.implement(this,i),this.setup=function(){var e=this,t=this.doc,n=this.session,i=this.$pos;this.selectionBefore=n.selection.toJSON(),n.selection.inMultiSelectMode&&n.selection.toSingleRange(),this.pos=t.createAnchor(i.row,i.column),this.markerId=n.addMarker(new r(i.row,i.column,i.row,i.column+this.length),this.mainClass,null,!1),this.pos.on(\"change\",function(t){n.removeMarker(e.markerId),e.markerId=n.addMarker(new r(t.value.row,t.value.column,t.value.row,t.value.column+e.length),e.mainClass,null,!1)}),this.others=[],this.$others.forEach(function(n){var r=t.createAnchor(n.row,n.column);e.others.push(r)}),n.setUndoSelect(!1)},this.showOtherMarkers=function(){if(this.othersActive)return;var e=this.session,t=this;this.othersActive=!0,this.others.forEach(function(n){n.markerId=e.addMarker(new r(n.row,n.column,n.row,n.column+t.length),t.othersClass,null,!1),n.on(\"change\",function(i){e.removeMarker(n.markerId),n.markerId=e.addMarker(new r(i.value.row,i.value.column,i.value.row,i.value.column+t.length),t.othersClass,null,!1)})})},this.hideOtherMarkers=function(){if(!this.othersActive)return;this.othersActive=!1;for(var e=0;e<this.others.length;e++)this.session.removeMarker(this.others[e].markerId)},this.onUpdate=function(e){var t=e;if(t.start.row!==t.end.row)return;if(t.start.row!==this.pos.row)return;if(this.$updating)return;this.$updating=!0;var n=e.action===\"insert\"?t.end.column-t.start.column:t.start.column-t.end.column;if(t.start.column>=this.pos.column&&t.start.column<=this.pos.column+this.length+1){var i=t.start.column-this.pos.column;this.length+=n;if(!this.session.$fromUndo){if(e.action===\"insert\")for(var s=this.others.length-1;s>=0;s--){var o=this.others[s],u={row:o.row,column:o.column+i};o.row===t.start.row&&t.start.column<o.column&&(u.column+=n),this.doc.insertMergedLines(u,e.lines)}else if(e.action===\"remove\")for(var s=this.others.length-1;s>=0;s--){var o=this.others[s],u={row:o.row,column:o.column+i};o.row===t.start.row&&t.start.column<o.column&&(u.column+=n),this.doc.remove(new r(u.row,u.column,u.row,u.column-n))}t.start.column===this.pos.column&&e.action===\"insert\"?setTimeout(function(){this.pos.setPosition(this.pos.row,this.pos.column-n);for(var e=0;e<this.others.length;e++){var r=this.others[e],i={row:r.row,column:r.column-n};r.row===t.start.row&&t.start.column<r.column&&(i.column+=n),r.setPosition(i.row,i.column)}}.bind(this),0):t.start.column===this.pos.column&&e.action===\"remove\"&&setTimeout(function(){for(var e=0;e<this.others.length;e++){var r=this.others[e];r.row===t.start.row&&t.start.column<r.column&&r.setPosition(r.row,r.column-n)}}.bind(this),0)}this.pos._emit(\"change\",{value:this.pos});for(var s=0;s<this.others.length;s++)this.others[s]._emit(\"change\",{value:this.others[s]})}this.$updating=!1},this.onCursorChange=function(e){if(this.$updating||!this.session)return;var t=this.session.selection.getCursor();t.row===this.pos.row&&t.column>=this.pos.column&&t.column<=this.pos.column+this.length?(this.showOtherMarkers(),this._emit(\"cursorEnter\",e)):(this.hideOtherMarkers(),this._emit(\"cursorLeave\",e))},this.detach=function(){this.session.removeMarker(this.markerId),this.hideOtherMarkers(),this.doc.removeEventListener(\"change\",this.$onUpdate),this.session.selection.removeEventListener(\"changeCursor\",this.$onCursorChange),this.pos.detach();for(var e=0;e<this.others.length;e++)this.others[e].detach();this.session.setUndoSelect(!0),this.session=null},this.cancel=function(){if(this.$undoStackDepth===-1)throw Error(\"Canceling placeholders only supported with undo manager attached to session.\");var e=this.session.getUndoManager(),t=(e.$undoStack||e.$undostack).length-this.$undoStackDepth;for(var n=0;n<t;n++)e.undo(!0);this.selectionBefore&&this.session.selection.fromJSON(this.selectionBefore)}}).call(o.prototype),t.PlaceHolder=o}),define(\"ace/mouse/multi_select_handler\",[\"require\",\"exports\",\"module\",\"ace/lib/event\",\"ace/lib/useragent\"],function(e,t,n){function s(e,t){return e.row==t.row&&e.column==t.column}function o(e){var t=e.domEvent,n=t.altKey,o=t.shiftKey,u=t.ctrlKey,a=e.getAccelKey(),f=e.getButton();u&&i.isMac&&(f=t.button);if(e.editor.inMultiSelectMode&&f==2){e.editor.textInput.onContextMenu(e.domEvent);return}if(!u&&!n&&!a){f===0&&e.editor.inMultiSelectMode&&e.editor.exitMultiSelectMode();return}if(f!==0)return;var l=e.editor,c=l.selection,h=l.inMultiSelectMode,p=e.getDocumentPosition(),d=c.getCursor(),v=e.inSelection()||c.isEmpty()&&s(p,d),m=e.x,g=e.y,y=function(e){m=e.clientX,g=e.clientY},b=l.session,w=l.renderer.pixelToScreenCoordinates(m,g),E=w,S;if(l.$mouseHandler.$enableJumpToDef)u&&n||a&&n?S=o?\"block\":\"add\":n&&l.$blockSelectEnabled&&(S=\"block\");else if(a&&!n){S=\"add\";if(!h&&o)return}else n&&l.$blockSelectEnabled&&(S=\"block\");S&&i.isMac&&t.ctrlKey&&l.$mouseHandler.cancelContextMenu();if(S==\"add\"){if(!h&&v)return;if(!h){var x=c.toOrientedRange();l.addSelectionMarker(x)}var T=c.rangeList.rangeAtPoint(p);l.$blockScrolling++,l.inVirtualSelectionMode=!0,o&&(T=null,x=c.ranges[0]||x,l.removeSelectionMarker(x)),l.once(\"mouseup\",function(){var e=c.toOrientedRange();T&&e.isEmpty()&&s(T.cursor,e.cursor)?c.substractPoint(e.cursor):(o?c.substractPoint(x.cursor):x&&(l.removeSelectionMarker(x),c.addRange(x)),c.addRange(e)),l.$blockScrolling--,l.inVirtualSelectionMode=!1})}else if(S==\"block\"){e.stop(),l.inVirtualSelectionMode=!0;var N,C=[],k=function(){var e=l.renderer.pixelToScreenCoordinates(m,g),t=b.screenToDocumentPosition(e.row,e.column);if(s(E,e)&&s(t,c.lead))return;E=e,l.$blockScrolling++,l.selection.moveToPosition(t),l.renderer.scrollCursorIntoView(),l.removeSelectionMarkers(C),C=c.rectangularRangeBlock(E,w),l.$mouseHandler.$clickSelection&&C.length==1&&C[0].isEmpty()&&(C[0]=l.$mouseHandler.$clickSelection.clone()),C.forEach(l.addSelectionMarker,l),l.updateSelectionMarkers(),l.$blockScrolling--};l.$blockScrolling++,h&&!a?c.toSingleRange():!h&&a&&(N=c.toOrientedRange(),l.addSelectionMarker(N)),o?w=b.documentToScreenPosition(c.lead):c.moveToPosition(p),l.$blockScrolling--,E={row:-1,column:-1};var L=function(e){clearInterval(O),l.removeSelectionMarkers(C),C.length||(C=[c.toOrientedRange()]),l.$blockScrolling++,N&&(l.removeSelectionMarker(N),c.toSingleRange(N));for(var t=0;t<C.length;t++)c.addRange(C[t]);l.inVirtualSelectionMode=!1,l.$mouseHandler.$clickSelection=null,l.$blockScrolling--},A=k;r.capture(l.container,y,L);var O=setInterval(function(){A()},20);return e.preventDefault()}}var r=e(\"../lib/event\"),i=e(\"../lib/useragent\");t.onMouseDown=o}),define(\"ace/commands/multi_select_commands\",[\"require\",\"exports\",\"module\",\"ace/keyboard/hash_handler\"],function(e,t,n){t.defaultCommands=[{name:\"addCursorAbove\",exec:function(e){e.selectMoreLines(-1)},bindKey:{win:\"Ctrl-Alt-Up\",mac:\"Ctrl-Alt-Up\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"addCursorBelow\",exec:function(e){e.selectMoreLines(1)},bindKey:{win:\"Ctrl-Alt-Down\",mac:\"Ctrl-Alt-Down\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"addCursorAboveSkipCurrent\",exec:function(e){e.selectMoreLines(-1,!0)},bindKey:{win:\"Ctrl-Alt-Shift-Up\",mac:\"Ctrl-Alt-Shift-Up\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"addCursorBelowSkipCurrent\",exec:function(e){e.selectMoreLines(1,!0)},bindKey:{win:\"Ctrl-Alt-Shift-Down\",mac:\"Ctrl-Alt-Shift-Down\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectMoreBefore\",exec:function(e){e.selectMore(-1)},bindKey:{win:\"Ctrl-Alt-Left\",mac:\"Ctrl-Alt-Left\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectMoreAfter\",exec:function(e){e.selectMore(1)},bindKey:{win:\"Ctrl-Alt-Right\",mac:\"Ctrl-Alt-Right\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectNextBefore\",exec:function(e){e.selectMore(-1,!0)},bindKey:{win:\"Ctrl-Alt-Shift-Left\",mac:\"Ctrl-Alt-Shift-Left\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"selectNextAfter\",exec:function(e){e.selectMore(1,!0)},bindKey:{win:\"Ctrl-Alt-Shift-Right\",mac:\"Ctrl-Alt-Shift-Right\"},scrollIntoView:\"cursor\",readOnly:!0},{name:\"splitIntoLines\",exec:function(e){e.multiSelect.splitIntoLines()},bindKey:{win:\"Ctrl-Alt-L\",mac:\"Ctrl-Alt-L\"},readOnly:!0},{name:\"alignCursors\",exec:function(e){e.alignCursors()},bindKey:{win:\"Ctrl-Alt-A\",mac:\"Ctrl-Alt-A\"},scrollIntoView:\"cursor\"},{name:\"findAll\",exec:function(e){e.findAll()},bindKey:{win:\"Ctrl-Alt-K\",mac:\"Ctrl-Alt-G\"},scrollIntoView:\"cursor\",readOnly:!0}],t.multiSelectCommands=[{name:\"singleSelection\",bindKey:\"esc\",exec:function(e){e.exitMultiSelectMode()},scrollIntoView:\"cursor\",readOnly:!0,isAvailable:function(e){return e&&e.inMultiSelectMode}}];var r=e(\"../keyboard/hash_handler\").HashHandler;t.keyboardHandler=new r(t.multiSelectCommands)}),define(\"ace/multi_select\",[\"require\",\"exports\",\"module\",\"ace/range_list\",\"ace/range\",\"ace/selection\",\"ace/mouse/multi_select_handler\",\"ace/lib/event\",\"ace/lib/lang\",\"ace/commands/multi_select_commands\",\"ace/search\",\"ace/edit_session\",\"ace/editor\",\"ace/config\"],function(e,t,n){function h(e,t,n){return c.$options.wrap=!0,c.$options.needle=t,c.$options.backwards=n==-1,c.find(e)}function v(e,t){return e.row==t.row&&e.column==t.column}function m(e){if(e.$multiselectOnSessionChange)return;e.$onAddRange=e.$onAddRange.bind(e),e.$onRemoveRange=e.$onRemoveRange.bind(e),e.$onMultiSelect=e.$onMultiSelect.bind(e),e.$onSingleSelect=e.$onSingleSelect.bind(e),e.$multiselectOnSessionChange=t.onSessionChange.bind(e),e.$checkMultiselectChange=e.$checkMultiselectChange.bind(e),e.$multiselectOnSessionChange(e),e.on(\"changeSession\",e.$multiselectOnSessionChange),e.on(\"mousedown\",o),e.commands.addCommands(f.defaultCommands),g(e)}function g(e){function r(t){n&&(e.renderer.setMouseCursor(\"\"),n=!1)}var t=e.textInput.getElement(),n=!1;u.addListener(t,\"keydown\",function(t){var i=t.keyCode==18&&!(t.ctrlKey||t.shiftKey||t.metaKey);e.$blockSelectEnabled&&i?n||(e.renderer.setMouseCursor(\"crosshair\"),n=!0):n&&r()}),u.addListener(t,\"keyup\",r),u.addListener(t,\"blur\",r)}var r=e(\"./range_list\").RangeList,i=e(\"./range\").Range,s=e(\"./selection\").Selection,o=e(\"./mouse/multi_select_handler\").onMouseDown,u=e(\"./lib/event\"),a=e(\"./lib/lang\"),f=e(\"./commands/multi_select_commands\");t.commands=f.defaultCommands.concat(f.multiSelectCommands);var l=e(\"./search\").Search,c=new l,p=e(\"./edit_session\").EditSession;(function(){this.getSelectionMarkers=function(){return this.$selectionMarkers}}).call(p.prototype),function(){this.ranges=null,this.rangeList=null,this.addRange=function(e,t){if(!e)return;if(!this.inMultiSelectMode&&this.rangeCount===0){var n=this.toOrientedRange();this.rangeList.add(n),this.rangeList.add(e);if(this.rangeList.ranges.length!=2)return this.rangeList.removeAll(),t||this.fromOrientedRange(e);this.rangeList.removeAll(),this.rangeList.add(n),this.$onAddRange(n)}e.cursor||(e.cursor=e.end);var r=this.rangeList.add(e);return this.$onAddRange(e),r.length&&this.$onRemoveRange(r),this.rangeCount>1&&!this.inMultiSelectMode&&(this._signal(\"multiSelect\"),this.inMultiSelectMode=!0,this.session.$undoSelect=!1,this.rangeList.attach(this.session)),t||this.fromOrientedRange(e)},this.toSingleRange=function(e){e=e||this.ranges[0];var t=this.rangeList.removeAll();t.length&&this.$onRemoveRange(t),e&&this.fromOrientedRange(e)},this.substractPoint=function(e){var t=this.rangeList.substractPoint(e);if(t)return this.$onRemoveRange(t),t[0]},this.mergeOverlappingRanges=function(){var e=this.rangeList.merge();e.length?this.$onRemoveRange(e):this.ranges[0]&&this.fromOrientedRange(this.ranges[0])},this.$onAddRange=function(e){this.rangeCount=this.rangeList.ranges.length,this.ranges.unshift(e),this._signal(\"addRange\",{range:e})},this.$onRemoveRange=function(e){this.rangeCount=this.rangeList.ranges.length;if(this.rangeCount==1&&this.inMultiSelectMode){var t=this.rangeList.ranges.pop();e.push(t),this.rangeCount=0}for(var n=e.length;n--;){var r=this.ranges.indexOf(e[n]);this.ranges.splice(r,1)}this._signal(\"removeRange\",{ranges:e}),this.rangeCount===0&&this.inMultiSelectMode&&(this.inMultiSelectMode=!1,this._signal(\"singleSelect\"),this.session.$undoSelect=!0,this.rangeList.detach(this.session)),t=t||this.ranges[0],t&&!t.isEqual(this.getRange())&&this.fromOrientedRange(t)},this.$initRangeList=function(){if(this.rangeList)return;this.rangeList=new r,this.ranges=[],this.rangeCount=0},this.getAllRanges=function(){return this.rangeCount?this.rangeList.ranges.concat():[this.getRange()]},this.splitIntoLines=function(){if(this.rangeCount>1){var e=this.rangeList.ranges,t=e[e.length-1],n=i.fromPoints(e[0].start,t.end);this.toSingleRange(),this.setSelectionRange(n,t.cursor==t.start)}else{var n=this.getRange(),r=this.isBackwards(),s=n.start.row,o=n.end.row;if(s==o){if(r)var u=n.end,a=n.start;else var u=n.start,a=n.end;this.addRange(i.fromPoints(a,a)),this.addRange(i.fromPoints(u,u));return}var f=[],l=this.getLineRange(s,!0);l.start.column=n.start.column,f.push(l);for(var c=s+1;c<o;c++)f.push(this.getLineRange(c,!0));l=this.getLineRange(o,!0),l.end.column=n.end.column,f.push(l),f.forEach(this.addRange,this)}},this.toggleBlockSelection=function(){if(this.rangeCount>1){var e=this.rangeList.ranges,t=e[e.length-1],n=i.fromPoints(e[0].start,t.end);this.toSingleRange(),this.setSelectionRange(n,t.cursor==t.start)}else{var r=this.session.documentToScreenPosition(this.selectionLead),s=this.session.documentToScreenPosition(this.selectionAnchor),o=this.rectangularRangeBlock(r,s);o.forEach(this.addRange,this)}},this.rectangularRangeBlock=function(e,t,n){var r=[],s=e.column<t.column;if(s)var o=e.column,u=t.column;else var o=t.column,u=e.column;var a=e.row<t.row;if(a)var f=e.row,l=t.row;else var f=t.row,l=e.row;o<0&&(o=0),f<0&&(f=0),f==l&&(n=!0);for(var c=f;c<=l;c++){var h=i.fromPoints(this.session.screenToDocumentPosition(c,o),this.session.screenToDocumentPosition(c,u));if(h.isEmpty()){if(p&&v(h.end,p))break;var p=h.end}h.cursor=s?h.start:h.end,r.push(h)}a&&r.reverse();if(!n){var d=r.length-1;while(r[d].isEmpty()&&d>0)d--;if(d>0){var m=0;while(r[m].isEmpty())m++}for(var g=d;g>=m;g--)r[g].isEmpty()&&r.splice(g,1)}return r}}.call(s.prototype);var d=e(\"./editor\").Editor;(function(){this.updateSelectionMarkers=function(){this.renderer.updateCursor(),this.renderer.updateBackMarkers()},this.addSelectionMarker=function(e){e.cursor||(e.cursor=e.end);var t=this.getSelectionStyle();return e.marker=this.session.addMarker(e,\"ace_selection\",t),this.session.$selectionMarkers.push(e),this.session.selectionMarkerCount=this.session.$selectionMarkers.length,e},this.removeSelectionMarker=function(e){if(!e.marker)return;this.session.removeMarker(e.marker);var t=this.session.$selectionMarkers.indexOf(e);t!=-1&&this.session.$selectionMarkers.splice(t,1),this.session.selectionMarkerCount=this.session.$selectionMarkers.length},this.removeSelectionMarkers=function(e){var t=this.session.$selectionMarkers;for(var n=e.length;n--;){var r=e[n];if(!r.marker)continue;this.session.removeMarker(r.marker);var i=t.indexOf(r);i!=-1&&t.splice(i,1)}this.session.selectionMarkerCount=t.length},this.$onAddRange=function(e){this.addSelectionMarker(e.range),this.renderer.updateCursor(),this.renderer.updateBackMarkers()},this.$onRemoveRange=function(e){this.removeSelectionMarkers(e.ranges),this.renderer.updateCursor(),this.renderer.updateBackMarkers()},this.$onMultiSelect=function(e){if(this.inMultiSelectMode)return;this.inMultiSelectMode=!0,this.setStyle(\"ace_multiselect\"),this.keyBinding.addKeyboardHandler(f.keyboardHandler),this.commands.setDefaultHandler(\"exec\",this.$onMultiSelectExec),this.renderer.updateCursor(),this.renderer.updateBackMarkers()},this.$onSingleSelect=function(e){if(this.session.multiSelect.inVirtualMode)return;this.inMultiSelectMode=!1,this.unsetStyle(\"ace_multiselect\"),this.keyBinding.removeKeyboardHandler(f.keyboardHandler),this.commands.removeDefaultHandler(\"exec\",this.$onMultiSelectExec),this.renderer.updateCursor(),this.renderer.updateBackMarkers(),this._emit(\"changeSelection\")},this.$onMultiSelectExec=function(e){var t=e.command,n=e.editor;if(!n.multiSelect)return;if(!t.multiSelectAction){var r=t.exec(n,e.args||{});n.multiSelect.addRange(n.multiSelect.toOrientedRange()),n.multiSelect.mergeOverlappingRanges()}else t.multiSelectAction==\"forEach\"?r=n.forEachSelection(t,e.args):t.multiSelectAction==\"forEachLine\"?r=n.forEachSelection(t,e.args,!0):t.multiSelectAction==\"single\"?(n.exitMultiSelectMode(),r=t.exec(n,e.args||{})):r=t.multiSelectAction(n,e.args||{});return r},this.forEachSelection=function(e,t,n){if(this.inVirtualSelectionMode)return;var r=n&&n.keepOrder,i=n==1||n&&n.$byLines,o=this.session,u=this.selection,a=u.rangeList,f=(r?u:a).ranges,l;if(!f.length)return e.exec?e.exec(this,t||{}):e(this,t||{});var c=u._eventRegistry;u._eventRegistry={};var h=new s(o);this.inVirtualSelectionMode=!0;for(var p=f.length;p--;){if(i)while(p>0&&f[p].start.row==f[p-1].end.row)p--;h.fromOrientedRange(f[p]),h.index=p,this.selection=o.selection=h;var d=e.exec?e.exec(this,t||{}):e(this,t||{});!l&&d!==undefined&&(l=d),h.toOrientedRange(f[p])}h.detach(),this.selection=o.selection=u,this.inVirtualSelectionMode=!1,u._eventRegistry=c,u.mergeOverlappingRanges();var v=this.renderer.$scrollAnimation;return this.onCursorChange(),this.onSelectionChange(),v&&v.from==v.to&&this.renderer.animateScrolling(v.from),l},this.exitMultiSelectMode=function(){if(!this.inMultiSelectMode||this.inVirtualSelectionMode)return;this.multiSelect.toSingleRange()},this.getSelectedText=function(){var e=\"\";if(this.inMultiSelectMode&&!this.inVirtualSelectionMode){var t=this.multiSelect.rangeList.ranges,n=[];for(var r=0;r<t.length;r++)n.push(this.session.getTextRange(t[r]));var i=this.session.getDocument().getNewLineCharacter();e=n.join(i),e.length==(n.length-1)*i.length&&(e=\"\")}else this.selection.isEmpty()||(e=this.session.getTextRange(this.getSelectionRange()));return e},this.$checkMultiselectChange=function(e,t){if(this.inMultiSelectMode&&!this.inVirtualSelectionMode){var n=this.multiSelect.ranges[0];if(this.multiSelect.isEmpty()&&t==this.multiSelect.anchor)return;var r=t==this.multiSelect.anchor?n.cursor==n.start?n.end:n.start:n.cursor;(r.row!=t.row||this.session.$clipPositionToDocument(r.row,r.column).column!=t.column)&&this.multiSelect.toSingleRange(this.multiSelect.toOrientedRange())}},this.findAll=function(e,t,n){t=t||{},t.needle=e||t.needle;if(t.needle==undefined){var r=this.selection.isEmpty()?this.selection.getWordRange():this.selection.getRange();t.needle=this.session.getTextRange(r)}this.$search.set(t);var i=this.$search.findAll(this.session);if(!i.length)return 0;this.$blockScrolling+=1;var s=this.multiSelect;n||s.toSingleRange(i[0]);for(var o=i.length;o--;)s.addRange(i[o],!0);return r&&s.rangeList.rangeAtPoint(r.start)&&s.addRange(r,!0),this.$blockScrolling-=1,i.length},this.selectMoreLines=function(e,t){var n=this.selection.toOrientedRange(),r=n.cursor==n.end,s=this.session.documentToScreenPosition(n.cursor);this.selection.$desiredColumn&&(s.column=this.selection.$desiredColumn);var o=this.session.screenToDocumentPosition(s.row+e,s.column);if(!n.isEmpty())var u=this.session.documentToScreenPosition(r?n.end:n.start),a=this.session.screenToDocumentPosition(u.row+e,u.column);else var a=o;if(r){var f=i.fromPoints(o,a);f.cursor=f.start}else{var f=i.fromPoints(a,o);f.cursor=f.end}f.desiredColumn=s.column;if(!this.selection.inMultiSelectMode)this.selection.addRange(n);else if(t)var l=n.cursor;this.selection.addRange(f),l&&this.selection.substractPoint(l)},this.transposeSelections=function(e){var t=this.session,n=t.multiSelect,r=n.ranges;for(var i=r.length;i--;){var s=r[i];if(s.isEmpty()){var o=t.getWordRange(s.start.row,s.start.column);s.start.row=o.start.row,s.start.column=o.start.column,s.end.row=o.end.row,s.end.column=o.end.column}}n.mergeOverlappingRanges();var u=[];for(var i=r.length;i--;){var s=r[i];u.unshift(t.getTextRange(s))}e<0?u.unshift(u.pop()):u.push(u.shift());for(var i=r.length;i--;){var s=r[i],o=s.clone();t.replace(s,u[i]),s.start.row=o.start.row,s.start.column=o.start.column}},this.selectMore=function(e,t,n){var r=this.session,i=r.multiSelect,s=i.toOrientedRange();if(s.isEmpty()){s=r.getWordRange(s.start.row,s.start.column),s.cursor=e==-1?s.start:s.end,this.multiSelect.addRange(s);if(n)return}var o=r.getTextRange(s),u=h(r,o,e);u&&(u.cursor=e==-1?u.start:u.end,this.$blockScrolling+=1,this.session.unfold(u),this.multiSelect.addRange(u),this.$blockScrolling-=1,this.renderer.scrollCursorIntoView(null,.5)),t&&this.multiSelect.substractPoint(s.cursor)},this.alignCursors=function(){var e=this.session,t=e.multiSelect,n=t.ranges,r=-1,s=n.filter(function(e){if(e.cursor.row==r)return!0;r=e.cursor.row});if(!n.length||s.length==n.length-1){var o=this.selection.getRange(),u=o.start.row,f=o.end.row,l=u==f;if(l){var c=this.session.getLength(),h;do h=this.session.getLine(f);while(/[=:]/.test(h)&&++f<c);do h=this.session.getLine(u);while(/[=:]/.test(h)&&--u>0);u<0&&(u=0),f>=c&&(f=c-1)}var p=this.session.removeFullLines(u,f);p=this.$reAlignText(p,l),this.session.insert({row:u,column:0},p.join(\"\\n\")+\"\\n\"),l||(o.start.column=0,o.end.column=p[p.length-1].length),this.selection.setRange(o)}else{s.forEach(function(e){t.substractPoint(e.cursor)});var d=0,v=Infinity,m=n.map(function(t){var n=t.cursor,r=e.getLine(n.row),i=r.substr(n.column).search(/\\S/g);return i==-1&&(i=0),n.column>d&&(d=n.column),i<v&&(v=i),i});n.forEach(function(t,n){var r=t.cursor,s=d-r.column,o=m[n]-v;s>o?e.insert(r,a.stringRepeat(\" \",s-o)):e.remove(new i(r.row,r.column,r.row,r.column-s+o)),t.start.column=t.end.column=d,t.start.row=t.end.row=r.row,t.cursor=t.end}),t.fromOrientedRange(n[0]),this.renderer.updateCursor(),this.renderer.updateBackMarkers()}},this.$reAlignText=function(e,t){function u(e){return a.stringRepeat(\" \",e)}function f(e){return e[2]?u(i)+e[2]+u(s-e[2].length+o)+e[4].replace(/^([=:])\\s+/,\"$1 \"):e[0]}function l(e){return e[2]?u(i+s-e[2].length)+e[2]+u(o,\" \")+e[4].replace(/^([=:])\\s+/,\"$1 \"):e[0]}function c(e){return e[2]?u(i)+e[2]+u(o)+e[4].replace(/^([=:])\\s+/,\"$1 \"):e[0]}var n=!0,r=!0,i,s,o;return e.map(function(e){var t=e.match(/(\\s*)(.*?)(\\s*)([=:].*)/);return t?i==null?(i=t[1].length,s=t[2].length,o=t[3].length,t):(i+s+o!=t[1].length+t[2].length+t[3].length&&(r=!1),i!=t[1].length&&(n=!1),i>t[1].length&&(i=t[1].length),s<t[2].length&&(s=t[2].length),o>t[3].length&&(o=t[3].length),t):[e]}).map(t?f:n?r?l:f:c)}}).call(d.prototype),t.onSessionChange=function(e){var t=e.session;t&&!t.multiSelect&&(t.$selectionMarkers=[],t.selection.$initRangeList(),t.multiSelect=t.selection),this.multiSelect=t&&t.multiSelect;var n=e.oldSession;n&&(n.multiSelect.off(\"addRange\",this.$onAddRange),n.multiSelect.off(\"removeRange\",this.$onRemoveRange),n.multiSelect.off(\"multiSelect\",this.$onMultiSelect),n.multiSelect.off(\"singleSelect\",this.$onSingleSelect),n.multiSelect.lead.off(\"change\",this.$checkMultiselectChange),n.multiSelect.anchor.off(\"change\",this.$checkMultiselectChange)),t&&(t.multiSelect.on(\"addRange\",this.$onAddRange),t.multiSelect.on(\"removeRange\",this.$onRemoveRange),t.multiSelect.on(\"multiSelect\",this.$onMultiSelect),t.multiSelect.on(\"singleSelect\",this.$onSingleSelect),t.multiSelect.lead.on(\"change\",this.$checkMultiselectChange),t.multiSelect.anchor.on(\"change\",this.$checkMultiselectChange)),t&&this.inMultiSelectMode!=t.selection.inMultiSelectMode&&(t.selection.inMultiSelectMode?this.$onMultiSelect():this.$onSingleSelect())},t.MultiSelect=m,e(\"./config\").defineOptions(d.prototype,\"editor\",{enableMultiselect:{set:function(e){m(this),e?(this.on(\"changeSession\",this.$multiselectOnSessionChange),this.on(\"mousedown\",o)):(this.off(\"changeSession\",this.$multiselectOnSessionChange),this.off(\"mousedown\",o))},value:!0},enableBlockSelect:{set:function(e){this.$blockSelectEnabled=e},value:!0}})}),define(\"ace/mode/folding/fold_mode\",[\"require\",\"exports\",\"module\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"../../range\").Range,i=t.FoldMode=function(){};(function(){this.foldingStartMarker=null,this.foldingStopMarker=null,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);return this.foldingStartMarker.test(r)?\"start\":t==\"markbeginend\"&&this.foldingStopMarker&&this.foldingStopMarker.test(r)?\"end\":\"\"},this.getFoldWidgetRange=function(e,t,n){return null},this.indentationBlock=function(e,t,n){var i=/\\S/,s=e.getLine(t),o=s.search(i);if(o==-1)return;var u=n||s.length,a=e.getLength(),f=t,l=t;while(++t<a){var c=e.getLine(t).search(i);if(c==-1)continue;if(c<=o)break;l=t}if(l>f){var h=e.getLine(l).length;return new r(f,u,l,h)}},this.openingBracketBlock=function(e,t,n,i,s){var o={row:n,column:i+1},u=e.$findClosingBracket(t,o,s);if(!u)return;var a=e.foldWidgets[u.row];return a==null&&(a=e.getFoldWidget(u.row)),a==\"start\"&&u.row>o.row&&(u.row--,u.column=e.getLine(u.row).length),r.fromPoints(o,u)},this.closingBracketBlock=function(e,t,n,i,s){var o={row:n,column:i},u=e.$findOpeningBracket(t,o);if(!u)return;return u.column++,o.column--,r.fromPoints(u,o)}}).call(i.prototype)}),define(\"ace/theme/textmate\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\"],function(e,t,n){\"use strict\";t.isDark=!1,t.cssClass=\"ace-tm\",t.cssText='.ace-tm .ace_gutter {background: #f0f0f0;color: #333;}.ace-tm .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-tm .ace_fold {background-color: #6B72E6;}.ace-tm {background-color: #FFFFFF;color: black;}.ace-tm .ace_cursor {color: black;}.ace-tm .ace_invisible {color: rgb(191, 191, 191);}.ace-tm .ace_storage,.ace-tm .ace_keyword {color: blue;}.ace-tm .ace_constant {color: rgb(197, 6, 11);}.ace-tm .ace_constant.ace_buildin {color: rgb(88, 72, 246);}.ace-tm .ace_constant.ace_language {color: rgb(88, 92, 246);}.ace-tm .ace_constant.ace_library {color: rgb(6, 150, 14);}.ace-tm .ace_invalid {background-color: rgba(255, 0, 0, 0.1);color: red;}.ace-tm .ace_support.ace_function {color: rgb(60, 76, 114);}.ace-tm .ace_support.ace_constant {color: rgb(6, 150, 14);}.ace-tm .ace_support.ace_type,.ace-tm .ace_support.ace_class {color: rgb(109, 121, 222);}.ace-tm .ace_keyword.ace_operator {color: rgb(104, 118, 135);}.ace-tm .ace_string {color: rgb(3, 106, 7);}.ace-tm .ace_comment {color: rgb(76, 136, 107);}.ace-tm .ace_comment.ace_doc {color: rgb(0, 102, 255);}.ace-tm .ace_comment.ace_doc.ace_tag {color: rgb(128, 159, 191);}.ace-tm .ace_constant.ace_numeric {color: rgb(0, 0, 205);}.ace-tm .ace_variable {color: rgb(49, 132, 149);}.ace-tm .ace_xml-pe {color: rgb(104, 104, 91);}.ace-tm .ace_entity.ace_name.ace_function {color: #0000A2;}.ace-tm .ace_heading {color: rgb(12, 7, 255);}.ace-tm .ace_list {color:rgb(185, 6, 144);}.ace-tm .ace_meta.ace_tag {color:rgb(0, 22, 142);}.ace-tm .ace_string.ace_regex {color: rgb(255, 0, 0)}.ace-tm .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-tm.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px white;}.ace-tm .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-tm .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-tm .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-tm .ace_marker-layer .ace_active-line {background: rgba(0, 0, 0, 0.07);}.ace-tm .ace_gutter-active-line {background-color : #dcdcdc;}.ace-tm .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-tm .ace_indent-guide {background: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==\") right repeat-y;}';var r=e(\"../lib/dom\");r.importCssString(t.cssText,t.cssClass)}),define(\"ace/line_widgets\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/dom\",\"ace/range\"],function(e,t,n){\"use strict\";function o(e){this.session=e,this.session.widgetManager=this,this.session.getRowLength=this.getRowLength,this.session.$getWidgetScreenLength=this.$getWidgetScreenLength,this.updateOnChange=this.updateOnChange.bind(this),this.renderWidgets=this.renderWidgets.bind(this),this.measureWidgets=this.measureWidgets.bind(this),this.session._changedWidgets=[],this.$onChangeEditor=this.$onChangeEditor.bind(this),this.session.on(\"change\",this.updateOnChange),this.session.on(\"changeEditor\",this.$onChangeEditor)}var r=e(\"./lib/oop\"),i=e(\"./lib/dom\"),s=e(\"./range\").Range;(function(){this.getRowLength=function(e){var t;return this.lineWidgets?t=this.lineWidgets[e]&&this.lineWidgets[e].rowCount||0:t=0,!this.$useWrapMode||!this.$wrapData[e]?1+t:this.$wrapData[e].length+1+t},this.$getWidgetScreenLength=function(){var e=0;return this.lineWidgets.forEach(function(t){t&&t.rowCount&&(e+=t.rowCount)}),e},this.$onChangeEditor=function(e){this.attach(e.editor)},this.attach=function(e){e&&e.widgetManager&&e.widgetManager!=this&&e.widgetManager.detach();if(this.editor==e)return;this.detach(),this.editor=e,e&&(e.widgetManager=this,e.renderer.on(\"beforeRender\",this.measureWidgets),e.renderer.on(\"afterRender\",this.renderWidgets))},this.detach=function(e){var t=this.editor;if(!t)return;this.editor=null,t.widgetManager=null,t.renderer.off(\"beforeRender\",this.measureWidgets),t.renderer.off(\"afterRender\",this.renderWidgets);var n=this.session.lineWidgets;n&&n.forEach(function(e){e&&e.el&&e.el.parentNode&&(e._inDocument=!1,e.el.parentNode.removeChild(e.el))})},this.updateOnChange=function(e){var t=this.session.lineWidgets;if(!t)return;var n=e.start.row,r=e.end.row-n;if(r!==0)if(e.action==\"remove\"){var i=t.splice(n+1,r);i.forEach(function(e){e&&this.removeLineWidget(e)},this),this.$updateRows()}else{var s=new Array(r);s.unshift(n,0),t.splice.apply(t,s),this.$updateRows()}},this.$updateRows=function(){var e=this.session.lineWidgets;if(!e)return;var t=!0;e.forEach(function(e,n){e&&(t=!1,e.row=n)}),t&&(this.session.lineWidgets=null)},this.addLineWidget=function(e){this.session.lineWidgets||(this.session.lineWidgets=new Array(this.session.getLength())),this.session.lineWidgets[e.row]=e;var t=this.editor.renderer;return e.html&&!e.el&&(e.el=i.createElement(\"div\"),e.el.innerHTML=e.html),e.el&&(i.addCssClass(e.el,\"ace_lineWidgetContainer\"),e.el.style.position=\"absolute\",e.el.style.zIndex=5,t.container.appendChild(e.el),e._inDocument=!0),e.coverGutter||(e.el.style.zIndex=3),e.pixelHeight||(e.pixelHeight=e.el.offsetHeight),e.rowCount==null&&(e.rowCount=e.pixelHeight/t.layerConfig.lineHeight),this.session._emit(\"changeFold\",{data:{start:{row:e.row}}}),this.$updateRows(),this.renderWidgets(null,t),e},this.removeLineWidget=function(e){e._inDocument=!1,e.el&&e.el.parentNode&&e.el.parentNode.removeChild(e.el);if(e.editor&&e.editor.destroy)try{e.editor.destroy()}catch(t){}this.session.lineWidgets&&(this.session.lineWidgets[e.row]=undefined),this.session._emit(\"changeFold\",{data:{start:{row:e.row}}}),this.$updateRows()},this.onWidgetChanged=function(e){this.session._changedWidgets.push(e),this.editor&&this.editor.renderer.updateFull()},this.measureWidgets=function(e,t){var n=this.session._changedWidgets,r=t.layerConfig;if(!n||!n.length)return;var i=Infinity;for(var s=0;s<n.length;s++){var o=n[s];o._inDocument||(o._inDocument=!0,t.container.appendChild(o.el)),o.h=o.el.offsetHeight,o.fixedWidth||(o.w=o.el.offsetWidth,o.screenWidth=Math.ceil(o.w/r.characterWidth));var u=o.h/r.lineHeight;o.coverLine&&(u-=this.session.getRowLineCount(o.row),u<0&&(u=0)),o.rowCount!=u&&(o.rowCount=u,o.row<i&&(i=o.row))}i!=Infinity&&(this.session._emit(\"changeFold\",{data:{start:{row:i}}}),this.session.lineWidgetWidth=null),this.session._changedWidgets=[]},this.renderWidgets=function(e,t){var n=t.layerConfig,r=this.session.lineWidgets;if(!r)return;var i=Math.min(this.firstRow,n.firstRow),s=Math.max(this.lastRow,n.lastRow,r.length);while(i>0&&!r[i])i--;this.firstRow=n.firstRow,this.lastRow=n.lastRow,t.$cursorLayer.config=n;for(var o=i;o<=s;o++){var u=r[o];if(!u||!u.el)continue;u._inDocument||(u._inDocument=!0,t.container.appendChild(u.el));var a=t.$cursorLayer.getPixelPosition({row:o,column:0},!0).top;u.coverLine||(a+=n.lineHeight*this.session.getRowLineCount(u.row)),u.el.style.top=a-n.offset+\"px\";var f=u.coverGutter?0:t.gutterWidth;u.fixedWidth||(f-=t.scrollLeft),u.el.style.left=f+\"px\",u.fixedWidth?u.el.style.right=t.scrollBar.getWidth()+\"px\":u.el.style.right=\"\"}}}).call(o.prototype),t.LineWidgets=o}),define(\"ace/ext/error_marker\",[\"require\",\"exports\",\"module\",\"ace/line_widgets\",\"ace/lib/dom\",\"ace/range\"],function(e,t,n){\"use strict\";function o(e,t,n){var r=0,i=e.length-1;while(r<=i){var s=r+i>>1,o=n(t,e[s]);if(o>0)r=s+1;else{if(!(o<0))return s;i=s-1}}return-(r+1)}function u(e,t,n){var r=e.getAnnotations().sort(s.comparePoints);if(!r.length)return;var i=o(r,{row:t,column:-1},s.comparePoints);i<0&&(i=-i-1),i>=r.length?i=n>0?0:r.length-1:i===0&&n<0&&(i=r.length-1);var u=r[i];if(!u||!n)return;if(u.row===t){do u=r[i+=n];while(u&&u.row===t);if(!u)return r.slice()}var a=[];t=u.row;do a[n<0?\"unshift\":\"push\"](u),u=r[i+=n];while(u&&u.row==t);return a.length&&a}var r=e(\"../line_widgets\").LineWidgets,i=e(\"../lib/dom\"),s=e(\"../range\").Range;t.showErrorMarker=function(e,t){var n=e.session;n.widgetManager||(n.widgetManager=new r(n),n.widgetManager.attach(e));var s=e.getCursorPosition(),o=s.row,a=n.lineWidgets&&n.lineWidgets[o];a?a.destroy():o-=t;var f=u(n,o,t),l;if(f){var c=f[0];s.column=(c.pos&&typeof c.column!=\"number\"?c.pos.sc:c.column)||0,s.row=c.row,l=e.renderer.$gutterLayer.$annotations[s.row]}else{if(a)return;l={text:[\"Looks good!\"],className:\"ace_ok\"}}e.session.unfold(s.row),e.selection.moveToPosition(s);var h={row:s.row,fixedWidth:!0,coverGutter:!0,el:i.createElement(\"div\")},p=h.el.appendChild(i.createElement(\"div\")),d=h.el.appendChild(i.createElement(\"div\"));d.className=\"error_widget_arrow \"+l.className;var v=e.renderer.$cursorLayer.getPixelPosition(s).left;d.style.left=v+e.renderer.gutterWidth-5+\"px\",h.el.className=\"error_widget_wrapper\",p.className=\"error_widget \"+l.className,p.innerHTML=l.text.join(\"<br>\"),p.appendChild(i.createElement(\"div\"));var m=function(e,t,n){if(t===0&&(n===\"esc\"||n===\"return\"))return h.destroy(),{command:\"null\"}};h.destroy=function(){if(e.$mouseHandler.isMousePressed)return;e.keyBinding.removeKeyboardHandler(m),n.widgetManager.removeLineWidget(h),e.off(\"changeSelection\",h.destroy),e.off(\"changeSession\",h.destroy),e.off(\"mouseup\",h.destroy),e.off(\"change\",h.destroy)},e.keyBinding.addKeyboardHandler(m),e.on(\"changeSelection\",h.destroy),e.on(\"changeSession\",h.destroy),e.on(\"mouseup\",h.destroy),e.on(\"change\",h.destroy),e.session.widgetManager.addLineWidget(h),h.el.onmousedown=e.focus.bind(e),e.renderer.scrollCursorIntoView(null,.5,{bottom:h.el.offsetHeight})},i.importCssString(\"    .error_widget_wrapper {        background: inherit;        color: inherit;        border:none    }    .error_widget {        border-top: solid 2px;        border-bottom: solid 2px;        margin: 5px 0;        padding: 10px 40px;        white-space: pre-wrap;    }    .error_widget.ace_error, .error_widget_arrow.ace_error{        border-color: #ff5a5a    }    .error_widget.ace_warning, .error_widget_arrow.ace_warning{        border-color: #F1D817    }    .error_widget.ace_info, .error_widget_arrow.ace_info{        border-color: #5a5a5a    }    .error_widget.ace_ok, .error_widget_arrow.ace_ok{        border-color: #5aaa5a    }    .error_widget_arrow {        position: absolute;        border: solid 5px;        border-top-color: transparent!important;        border-right-color: transparent!important;        border-left-color: transparent!important;        top: -5px;    }\",\"\")}),define(\"ace/ace\",[\"require\",\"exports\",\"module\",\"ace/lib/fixoldbrowsers\",\"ace/lib/dom\",\"ace/lib/event\",\"ace/editor\",\"ace/edit_session\",\"ace/undomanager\",\"ace/virtual_renderer\",\"ace/worker/worker_client\",\"ace/keyboard/hash_handler\",\"ace/placeholder\",\"ace/multi_select\",\"ace/mode/folding/fold_mode\",\"ace/theme/textmate\",\"ace/ext/error_marker\",\"ace/config\"],function(e,t,n){\"use strict\";e(\"./lib/fixoldbrowsers\");var r=e(\"./lib/dom\"),i=e(\"./lib/event\"),s=e(\"./editor\").Editor,o=e(\"./edit_session\").EditSession,u=e(\"./undomanager\").UndoManager,a=e(\"./virtual_renderer\").VirtualRenderer;e(\"./worker/worker_client\"),e(\"./keyboard/hash_handler\"),e(\"./placeholder\"),e(\"./multi_select\"),e(\"./mode/folding/fold_mode\"),e(\"./theme/textmate\"),e(\"./ext/error_marker\"),t.config=e(\"./config\"),t.require=e,t.edit=function(e){if(typeof e==\"string\"){var n=e;e=document.getElementById(n);if(!e)throw new Error(\"ace.edit can't find div #\"+n)}if(e&&e.env&&e.env.editor instanceof s)return e.env.editor;var o=\"\";if(e&&/input|textarea/i.test(e.tagName)){var u=e;o=u.value,e=r.createElement(\"pre\"),u.parentNode.replaceChild(e,u)}else e&&(o=r.getInnerText(e),e.innerHTML=\"\");var f=t.createEditSession(o),l=new s(new a(e));l.setSession(f);var c={document:f,editor:l,onResize:l.resize.bind(l,null)};return u&&(c.textarea=u),i.addListener(window,\"resize\",c.onResize),l.on(\"destroy\",function(){i.removeListener(window,\"resize\",c.onResize),c.editor.container.env=null}),l.container.env=l.env=c,l},t.createEditSession=function(e,t){var n=new o(e,t);return n.setUndoManager(new u),n},t.EditSession=o,t.UndoManager=u});\n            (function() {\n                window.require([\"ace/ace\"], function(a) {\n                    a && a.config.init(true);\n                    if (!window.ace)\n                        window.ace = a;\n                    for (var key in a) if (a.hasOwnProperty(key))\n                        window.ace[key] = a[key];\n                });\n            })();\n        "
  },
  {
    "path": "js/admin/ace/mode-twig.js",
    "content": "define(\"ace/mode/doc_comment_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/text_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./text_highlight_rules\").TextHighlightRules,s=function(){this.$rules={start:[{token:\"comment.doc.tag\",regex:\"@[\\\\w\\\\d_]+\"},s.getTagRule(),{defaultToken:\"comment.doc\",caseInsensitive:!0}]}};r.inherits(s,i),s.getTagRule=function(e){return{token:\"comment.doc.tag.storage.type\",regex:\"\\\\b(?:TODO|FIXME|XXX|HACK)\\\\b\"}},s.getStartRule=function(e){return{token:\"comment.doc\",regex:\"\\\\/\\\\*(?=\\\\*)\",next:e}},s.getEndRule=function(e){return{token:\"comment.doc\",regex:\"\\\\*\\\\/\",next:e}},t.DocCommentHighlightRules=s}),define(\"ace/mode/javascript_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/doc_comment_highlight_rules\",\"ace/mode/text_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./doc_comment_highlight_rules\").DocCommentHighlightRules,s=e(\"./text_highlight_rules\").TextHighlightRules,o=function(e){var t=this.createKeywordMapper({\"variable.language\":\"Array|Boolean|Date|Function|Iterator|Number|Object|RegExp|String|Proxy|Namespace|QName|XML|XMLList|ArrayBuffer|Float32Array|Float64Array|Int16Array|Int32Array|Int8Array|Uint16Array|Uint32Array|Uint8Array|Uint8ClampedArray|Error|EvalError|InternalError|RangeError|ReferenceError|StopIteration|SyntaxError|TypeError|URIError|decodeURI|decodeURIComponent|encodeURI|encodeURIComponent|eval|isFinite|isNaN|parseFloat|parseInt|JSON|Math|this|arguments|prototype|window|document\",keyword:\"const|yield|import|get|set|break|case|catch|continue|default|delete|do|else|finally|for|function|if|in|instanceof|new|return|switch|throw|try|typeof|let|var|while|with|debugger|__parent__|__count__|escape|unescape|with|__proto__|class|enum|extends|super|export|implements|private|public|interface|package|protected|static\",\"storage.type\":\"const|let|var|function\",\"constant.language\":\"null|Infinity|NaN|undefined\",\"support.function\":\"alert\",\"constant.language.boolean\":\"true|false\"},\"identifier\"),n=\"case|do|else|finally|in|instanceof|return|throw|try|typeof|yield|void\",r=\"[a-zA-Z\\\\$_\\u00a1-\\uffff][a-zA-Z\\\\d\\\\$_\\u00a1-\\uffff]*\\\\b\",s=\"\\\\\\\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|[0-2][0-7]{0,2}|3[0-6][0-7]?|37[0-7]?|[4-7][0-7]?|.)\";this.$rules={no_regex:[{token:\"comment\",regex:\"\\\\/\\\\/\",next:\"line_comment\"},i.getStartRule(\"doc-start\"),{token:\"comment\",regex:/\\/\\*/,next:\"comment\"},{token:\"string\",regex:\"'(?=.)\",next:\"qstring\"},{token:\"string\",regex:'\"(?=.)',next:\"qqstring\"},{token:\"constant.numeric\",regex:/0[xX][0-9a-fA-F]+\\b/},{token:\"constant.numeric\",regex:/[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b/},{token:[\"storage.type\",\"punctuation.operator\",\"support.function\",\"punctuation.operator\",\"entity.name.function\",\"text\",\"keyword.operator\"],regex:\"(\"+r+\")(\\\\.)(prototype)(\\\\.)(\"+r+\")(\\\\s*)(=)\",next:\"function_arguments\"},{token:[\"storage.type\",\"punctuation.operator\",\"entity.name.function\",\"text\",\"keyword.operator\",\"text\",\"storage.type\",\"text\",\"paren.lparen\"],regex:\"(\"+r+\")(\\\\.)(\"+r+\")(\\\\s*)(=)(\\\\s*)(function)(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:[\"entity.name.function\",\"text\",\"keyword.operator\",\"text\",\"storage.type\",\"text\",\"paren.lparen\"],regex:\"(\"+r+\")(\\\\s*)(=)(\\\\s*)(function)(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:[\"storage.type\",\"punctuation.operator\",\"entity.name.function\",\"text\",\"keyword.operator\",\"text\",\"storage.type\",\"text\",\"entity.name.function\",\"text\",\"paren.lparen\"],regex:\"(\"+r+\")(\\\\.)(\"+r+\")(\\\\s*)(=)(\\\\s*)(function)(\\\\s+)(\\\\w+)(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:[\"storage.type\",\"text\",\"entity.name.function\",\"text\",\"paren.lparen\"],regex:\"(function)(\\\\s+)(\"+r+\")(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:[\"entity.name.function\",\"text\",\"punctuation.operator\",\"text\",\"storage.type\",\"text\",\"paren.lparen\"],regex:\"(\"+r+\")(\\\\s*)(:)(\\\\s*)(function)(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:[\"text\",\"text\",\"storage.type\",\"text\",\"paren.lparen\"],regex:\"(:)(\\\\s*)(function)(\\\\s*)(\\\\()\",next:\"function_arguments\"},{token:\"keyword\",regex:\"(?:\"+n+\")\\\\b\",next:\"start\"},{token:[\"punctuation.operator\",\"support.function\"],regex:/(\\.)(s(?:h(?:ift|ow(?:Mod(?:elessDialog|alDialog)|Help))|croll(?:X|By(?:Pages|Lines)?|Y|To)?|t(?:op|rike)|i(?:n|zeToContent|debar|gnText)|ort|u(?:p|b(?:str(?:ing)?)?)|pli(?:ce|t)|e(?:nd|t(?:Re(?:sizable|questHeader)|M(?:i(?:nutes|lliseconds)|onth)|Seconds|Ho(?:tKeys|urs)|Year|Cursor|Time(?:out)?|Interval|ZOptions|Date|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Date|FullYear)|FullYear|Active)|arch)|qrt|lice|avePreferences|mall)|h(?:ome|andleEvent)|navigate|c(?:har(?:CodeAt|At)|o(?:s|n(?:cat|textual|firm)|mpile)|eil|lear(?:Timeout|Interval)?|a(?:ptureEvents|ll)|reate(?:StyleSheet|Popup|EventObject))|t(?:o(?:GMTString|S(?:tring|ource)|U(?:TCString|pperCase)|Lo(?:caleString|werCase))|est|a(?:n|int(?:Enabled)?))|i(?:s(?:NaN|Finite)|ndexOf|talics)|d(?:isableExternalCapture|ump|etachEvent)|u(?:n(?:shift|taint|escape|watch)|pdateCommands)|j(?:oin|avaEnabled)|p(?:o(?:p|w)|ush|lugins.refresh|a(?:ddings|rse(?:Int|Float)?)|r(?:int|ompt|eference))|e(?:scape|nableExternalCapture|val|lementFromPoint|x(?:p|ec(?:Script|Command)?))|valueOf|UTC|queryCommand(?:State|Indeterm|Enabled|Value)|f(?:i(?:nd|le(?:ModifiedDate|Size|CreatedDate|UpdatedDate)|xed)|o(?:nt(?:size|color)|rward)|loor|romCharCode)|watch|l(?:ink|o(?:ad|g)|astIndexOf)|a(?:sin|nchor|cos|t(?:tachEvent|ob|an(?:2)?)|pply|lert|b(?:s|ort))|r(?:ou(?:nd|teEvents)|e(?:size(?:By|To)|calc|turnValue|place|verse|l(?:oad|ease(?:Capture|Events)))|andom)|g(?:o|et(?:ResponseHeader|M(?:i(?:nutes|lliseconds)|onth)|Se(?:conds|lection)|Hours|Year|Time(?:zoneOffset)?|Da(?:y|te)|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Da(?:y|te)|FullYear)|FullYear|A(?:ttention|llResponseHeaders)))|m(?:in|ove(?:B(?:y|elow)|To(?:Absolute)?|Above)|ergeAttributes|a(?:tch|rgins|x))|b(?:toa|ig|o(?:ld|rderWidths)|link|ack))\\b(?=\\()/},{token:[\"punctuation.operator\",\"support.function.dom\"],regex:/(\\.)(s(?:ub(?:stringData|mit)|plitText|e(?:t(?:NamedItem|Attribute(?:Node)?)|lect))|has(?:ChildNodes|Feature)|namedItem|c(?:l(?:ick|o(?:se|neNode))|reate(?:C(?:omment|DATASection|aption)|T(?:Head|extNode|Foot)|DocumentFragment|ProcessingInstruction|E(?:ntityReference|lement)|Attribute))|tabIndex|i(?:nsert(?:Row|Before|Cell|Data)|tem)|open|delete(?:Row|C(?:ell|aption)|T(?:Head|Foot)|Data)|focus|write(?:ln)?|a(?:dd|ppend(?:Child|Data))|re(?:set|place(?:Child|Data)|move(?:NamedItem|Child|Attribute(?:Node)?)?)|get(?:NamedItem|Element(?:sBy(?:Name|TagName|ClassName)|ById)|Attribute(?:Node)?)|blur)\\b(?=\\()/},{token:[\"punctuation.operator\",\"support.constant\"],regex:/(\\.)(s(?:ystemLanguage|cr(?:ipts|ollbars|een(?:X|Y|Top|Left))|t(?:yle(?:Sheets)?|atus(?:Text|bar)?)|ibling(?:Below|Above)|ource|uffixes|e(?:curity(?:Policy)?|l(?:ection|f)))|h(?:istory|ost(?:name)?|as(?:h|Focus))|y|X(?:MLDocument|SLDocument)|n(?:ext|ame(?:space(?:s|URI)|Prop))|M(?:IN_VALUE|AX_VALUE)|c(?:haracterSet|o(?:n(?:structor|trollers)|okieEnabled|lorDepth|mp(?:onents|lete))|urrent|puClass|l(?:i(?:p(?:boardData)?|entInformation)|osed|asses)|alle(?:e|r)|rypto)|t(?:o(?:olbar|p)|ext(?:Transform|Indent|Decoration|Align)|ags)|SQRT(?:1_2|2)|i(?:n(?:ner(?:Height|Width)|put)|ds|gnoreCase)|zIndex|o(?:scpu|n(?:readystatechange|Line)|uter(?:Height|Width)|p(?:sProfile|ener)|ffscreenBuffering)|NEGATIVE_INFINITY|d(?:i(?:splay|alog(?:Height|Top|Width|Left|Arguments)|rectories)|e(?:scription|fault(?:Status|Ch(?:ecked|arset)|View)))|u(?:ser(?:Profile|Language|Agent)|n(?:iqueID|defined)|pdateInterval)|_content|p(?:ixelDepth|ort|ersonalbar|kcs11|l(?:ugins|atform)|a(?:thname|dding(?:Right|Bottom|Top|Left)|rent(?:Window|Layer)?|ge(?:X(?:Offset)?|Y(?:Offset)?))|r(?:o(?:to(?:col|type)|duct(?:Sub)?|mpter)|e(?:vious|fix)))|e(?:n(?:coding|abledPlugin)|x(?:ternal|pando)|mbeds)|v(?:isibility|endor(?:Sub)?|Linkcolor)|URLUnencoded|P(?:I|OSITIVE_INFINITY)|f(?:ilename|o(?:nt(?:Size|Family|Weight)|rmName)|rame(?:s|Element)|gColor)|E|whiteSpace|l(?:i(?:stStyleType|n(?:eHeight|kColor))|o(?:ca(?:tion(?:bar)?|lName)|wsrc)|e(?:ngth|ft(?:Context)?)|a(?:st(?:M(?:odified|atch)|Index|Paren)|yer(?:s|X)|nguage))|a(?:pp(?:MinorVersion|Name|Co(?:deName|re)|Version)|vail(?:Height|Top|Width|Left)|ll|r(?:ity|guments)|Linkcolor|bove)|r(?:ight(?:Context)?|e(?:sponse(?:XML|Text)|adyState))|global|x|m(?:imeTypes|ultiline|enubar|argin(?:Right|Bottom|Top|Left))|L(?:N(?:10|2)|OG(?:10E|2E))|b(?:o(?:ttom|rder(?:Width|RightWidth|BottomWidth|Style|Color|TopWidth|LeftWidth))|ufferDepth|elow|ackground(?:Color|Image)))\\b/},{token:[\"support.constant\"],regex:/that\\b/},{token:[\"storage.type\",\"punctuation.operator\",\"support.function.firebug\"],regex:/(console)(\\.)(warn|info|log|error|time|trace|timeEnd|assert)\\b/},{token:t,regex:r},{token:\"keyword.operator\",regex:/--|\\+\\+|===|==|=|!=|!==|<=|>=|<<=|>>=|>>>=|<>|<|>|!|&&|\\|\\||\\?\\:|[!$%&*+\\-~\\/^]=?/,next:\"start\"},{token:\"punctuation.operator\",regex:/[?:,;.]/,next:\"start\"},{token:\"paren.lparen\",regex:/[\\[({]/,next:\"start\"},{token:\"paren.rparen\",regex:/[\\])}]/},{token:\"comment\",regex:/^#!.*$/}],start:[i.getStartRule(\"doc-start\"),{token:\"comment\",regex:\"\\\\/\\\\*\",next:\"comment_regex_allowed\"},{token:\"comment\",regex:\"\\\\/\\\\/\",next:\"line_comment_regex_allowed\"},{token:\"string.regexp\",regex:\"\\\\/\",next:\"regex\"},{token:\"text\",regex:\"\\\\s+|^$\",next:\"start\"},{token:\"empty\",regex:\"\",next:\"no_regex\"}],regex:[{token:\"regexp.keyword.operator\",regex:\"\\\\\\\\(?:u[\\\\da-fA-F]{4}|x[\\\\da-fA-F]{2}|.)\"},{token:\"string.regexp\",regex:\"/[sxngimy]*\",next:\"no_regex\"},{token:\"invalid\",regex:/\\{\\d+\\b,?\\d*\\}[+*]|[+*$^?][+*]|[$^][?]|\\?{3,}/},{token:\"constant.language.escape\",regex:/\\(\\?[:=!]|\\)|\\{\\d+\\b,?\\d*\\}|[+*]\\?|[()$^+*?.]/},{token:\"constant.language.delimiter\",regex:/\\|/},{token:\"constant.language.escape\",regex:/\\[\\^?/,next:\"regex_character_class\"},{token:\"empty\",regex:\"$\",next:\"no_regex\"},{defaultToken:\"string.regexp\"}],regex_character_class:[{token:\"regexp.charclass.keyword.operator\",regex:\"\\\\\\\\(?:u[\\\\da-fA-F]{4}|x[\\\\da-fA-F]{2}|.)\"},{token:\"constant.language.escape\",regex:\"]\",next:\"regex\"},{token:\"constant.language.escape\",regex:\"-\"},{token:\"empty\",regex:\"$\",next:\"no_regex\"},{defaultToken:\"string.regexp.charachterclass\"}],function_arguments:[{token:\"variable.parameter\",regex:r},{token:\"punctuation.operator\",regex:\"[, ]+\"},{token:\"punctuation.operator\",regex:\"$\"},{token:\"empty\",regex:\"\",next:\"no_regex\"}],comment_regex_allowed:[i.getTagRule(),{token:\"comment\",regex:\"\\\\*\\\\/\",next:\"start\"},{defaultToken:\"comment\",caseInsensitive:!0}],comment:[i.getTagRule(),{token:\"comment\",regex:\"\\\\*\\\\/\",next:\"no_regex\"},{defaultToken:\"comment\",caseInsensitive:!0}],line_comment_regex_allowed:[i.getTagRule(),{token:\"comment\",regex:\"$|^\",next:\"start\"},{defaultToken:\"comment\",caseInsensitive:!0}],line_comment:[i.getTagRule(),{token:\"comment\",regex:\"$|^\",next:\"no_regex\"},{defaultToken:\"comment\",caseInsensitive:!0}],qqstring:[{token:\"constant.language.escape\",regex:s},{token:\"string\",regex:\"\\\\\\\\$\",next:\"qqstring\"},{token:\"string\",regex:'\"|$',next:\"no_regex\"},{defaultToken:\"string\"}],qstring:[{token:\"constant.language.escape\",regex:s},{token:\"string\",regex:\"\\\\\\\\$\",next:\"qstring\"},{token:\"string\",regex:\"'|$\",next:\"no_regex\"},{defaultToken:\"string\"}]},(!e||!e.noES6)&&this.$rules.no_regex.unshift({regex:\"[{}]\",onMatch:function(e,t,n){this.next=e==\"{\"?this.nextState:\"\";if(e==\"{\"&&n.length)return n.unshift(\"start\",t),\"paren\";if(e==\"}\"&&n.length){n.shift(),this.next=n.shift();if(this.next.indexOf(\"string\")!=-1)return\"paren.quasi.end\"}return e==\"{\"?\"paren.lparen\":\"paren.rparen\"},nextState:\"start\"},{token:\"string.quasi.start\",regex:/`/,push:[{token:\"constant.language.escape\",regex:s},{token:\"paren.quasi.start\",regex:/\\${/,push:\"start\"},{token:\"string.quasi.end\",regex:/`/,next:\"pop\"},{defaultToken:\"string.quasi\"}]}),this.embedRules(i,\"doc-\",[i.getEndRule(\"no_regex\")]),this.normalizeRules()};r.inherits(o,s),t.JavaScriptHighlightRules=o}),define(\"ace/mode/matching_brace_outdent\",[\"require\",\"exports\",\"module\",\"ace/range\"],function(e,t,n){\"use strict\";var r=e(\"../range\").Range,i=function(){};(function(){this.checkOutdent=function(e,t){return/^\\s+$/.test(e)?/^\\s*\\}/.test(t):!1},this.autoOutdent=function(e,t){var n=e.getLine(t),i=n.match(/^(\\s*\\})/);if(!i)return 0;var s=i[1].length,o=e.findMatchingBracket({row:t,column:s});if(!o||o.row==t)return 0;var u=this.$getIndent(e.getLine(o.row));e.replace(new r(t,0,t,s-1),u)},this.$getIndent=function(e){return e.match(/^\\s*/)[0]}}).call(i.prototype),t.MatchingBraceOutdent=i}),define(\"ace/mode/behaviour/cstyle\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/behaviour\",\"ace/token_iterator\",\"ace/lib/lang\"],function(e,t,n){\"use strict\";var r=e(\"../../lib/oop\"),i=e(\"../behaviour\").Behaviour,s=e(\"../../token_iterator\").TokenIterator,o=e(\"../../lib/lang\"),u=[\"text\",\"paren.rparen\",\"punctuation.operator\"],a=[\"text\",\"paren.rparen\",\"punctuation.operator\",\"comment\"],f,l={},c=function(e){var t=-1;e.multiSelect&&(t=e.selection.index,l.rangeCount!=e.multiSelect.rangeCount&&(l={rangeCount:e.multiSelect.rangeCount}));if(l[t])return f=l[t];f=l[t]={autoInsertedBrackets:0,autoInsertedRow:-1,autoInsertedLineEnd:\"\",maybeInsertedBrackets:0,maybeInsertedRow:-1,maybeInsertedLineStart:\"\",maybeInsertedLineEnd:\"\"}},h=function(){this.add(\"braces\",\"insertion\",function(e,t,n,r,i){var s=n.getCursorPosition(),u=r.doc.getLine(s.row);if(i==\"{\"){c(n);var a=n.getSelectionRange(),l=r.doc.getTextRange(a);if(l!==\"\"&&l!==\"{\"&&n.getWrapBehavioursEnabled())return{text:\"{\"+l+\"}\",selection:!1};if(h.isSaneInsertion(n,r))return/[\\]\\}\\)]/.test(u[s.column])||n.inMultiSelectMode?(h.recordAutoInsert(n,r,\"}\"),{text:\"{}\",selection:[1,1]}):(h.recordMaybeInsert(n,r,\"{\"),{text:\"{\",selection:[1,1]})}else if(i==\"}\"){c(n);var p=u.substring(s.column,s.column+1);if(p==\"}\"){var d=r.$findOpeningBracket(\"}\",{column:s.column+1,row:s.row});if(d!==null&&h.isAutoInsertedClosing(s,u,i))return h.popAutoInsertedClosing(),{text:\"\",selection:[1,1]}}}else{if(i==\"\\n\"||i==\"\\r\\n\"){c(n);var v=\"\";h.isMaybeInsertedClosing(s,u)&&(v=o.stringRepeat(\"}\",f.maybeInsertedBrackets),h.clearMaybeInsertedClosing());var p=u.substring(s.column,s.column+1);if(p===\"}\"){var m=r.findMatchingBracket({row:s.row,column:s.column+1},\"}\");if(!m)return null;var g=this.$getIndent(r.getLine(m.row))}else{if(!v){h.clearMaybeInsertedClosing();return}var g=this.$getIndent(u)}var y=g+r.getTabString();return{text:\"\\n\"+y+\"\\n\"+g+v,selection:[1,y.length,1,y.length]}}h.clearMaybeInsertedClosing()}}),this.add(\"braces\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s==\"{\"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.end.column,i.end.column+1);if(u==\"}\")return i.end.column++,i;f.maybeInsertedBrackets--}}),this.add(\"parens\",\"insertion\",function(e,t,n,r,i){if(i==\"(\"){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==\"\"&&n.getWrapBehavioursEnabled())return{text:\"(\"+o+\")\",selection:!1};if(h.isSaneInsertion(n,r))return h.recordAutoInsert(n,r,\")\"),{text:\"()\",selection:[1,1]}}else if(i==\")\"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f==\")\"){var l=r.$findOpeningBracket(\")\",{column:u.column+1,row:u.row});if(l!==null&&h.isAutoInsertedClosing(u,a,i))return h.popAutoInsertedClosing(),{text:\"\",selection:[1,1]}}}}),this.add(\"parens\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s==\"(\"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==\")\")return i.end.column++,i}}),this.add(\"brackets\",\"insertion\",function(e,t,n,r,i){if(i==\"[\"){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==\"\"&&n.getWrapBehavioursEnabled())return{text:\"[\"+o+\"]\",selection:!1};if(h.isSaneInsertion(n,r))return h.recordAutoInsert(n,r,\"]\"),{text:\"[]\",selection:[1,1]}}else if(i==\"]\"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f==\"]\"){var l=r.$findOpeningBracket(\"]\",{column:u.column+1,row:u.row});if(l!==null&&h.isAutoInsertedClosing(u,a,i))return h.popAutoInsertedClosing(),{text:\"\",selection:[1,1]}}}}),this.add(\"brackets\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s==\"[\"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==\"]\")return i.end.column++,i}}),this.add(\"string_dquotes\",\"insertion\",function(e,t,n,r,i){if(i=='\"'||i==\"'\"){c(n);var s=i,o=n.getSelectionRange(),u=r.doc.getTextRange(o);if(u!==\"\"&&u!==\"'\"&&u!='\"'&&n.getWrapBehavioursEnabled())return{text:s+u+s,selection:!1};if(!u){var a=n.getCursorPosition(),f=r.doc.getLine(a.row),l=f.substring(a.column-1,a.column),h=f.substring(a.column,a.column+1),p=r.getTokenAt(a.row,a.column),d=r.getTokenAt(a.row,a.column+1);if(l==\"\\\\\"&&p&&/escape/.test(p.type))return null;var v=p&&/string/.test(p.type),m=!d||/string/.test(d.type),g;if(h==s)g=v!==m;else{if(v&&!m)return null;if(v&&m)return null;var y=r.$mode.tokenRe;y.lastIndex=0;var b=y.test(l);y.lastIndex=0;var w=y.test(l);if(b||w)return null;if(h&&!/[\\s;,.})\\]\\\\]/.test(h))return null;g=!0}return{text:g?s+s:\"\",selection:[1,1]}}}}),this.add(\"string_dquotes\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='\"'||s==\"'\")){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}})};h.isSaneInsertion=function(e,t){var n=e.getCursorPosition(),r=new s(t,n.row,n.column);if(!this.$matchTokenType(r.getCurrentToken()||\"text\",u)){var i=new s(t,n.row,n.column+1);if(!this.$matchTokenType(i.getCurrentToken()||\"text\",u))return!1}return r.stepForward(),r.getCurrentTokenRow()!==n.row||this.$matchTokenType(r.getCurrentToken()||\"text\",a)},h.$matchTokenType=function(e,t){return t.indexOf(e.type||e)>-1},h.recordAutoInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isAutoInsertedClosing(r,i,f.autoInsertedLineEnd[0])||(f.autoInsertedBrackets=0),f.autoInsertedRow=r.row,f.autoInsertedLineEnd=n+i.substr(r.column),f.autoInsertedBrackets++},h.recordMaybeInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isMaybeInsertedClosing(r,i)||(f.maybeInsertedBrackets=0),f.maybeInsertedRow=r.row,f.maybeInsertedLineStart=i.substr(0,r.column)+n,f.maybeInsertedLineEnd=i.substr(r.column),f.maybeInsertedBrackets++},h.isAutoInsertedClosing=function(e,t,n){return f.autoInsertedBrackets>0&&e.row===f.autoInsertedRow&&n===f.autoInsertedLineEnd[0]&&t.substr(e.column)===f.autoInsertedLineEnd},h.isMaybeInsertedClosing=function(e,t){return f.maybeInsertedBrackets>0&&e.row===f.maybeInsertedRow&&t.substr(e.column)===f.maybeInsertedLineEnd&&t.substr(0,e.column)==f.maybeInsertedLineStart},h.popAutoInsertedClosing=function(){f.autoInsertedLineEnd=f.autoInsertedLineEnd.substr(1),f.autoInsertedBrackets--},h.clearMaybeInsertedClosing=function(){f&&(f.maybeInsertedBrackets=0,f.maybeInsertedRow=-1)},r.inherits(h,i),t.CstyleBehaviour=h}),define(\"ace/mode/folding/cstyle\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/range\",\"ace/mode/folding/fold_mode\"],function(e,t,n){\"use strict\";var r=e(\"../../lib/oop\"),i=e(\"../../range\").Range,s=e(\"./fold_mode\").FoldMode,o=t.FoldMode=function(e){e&&(this.foldingStartMarker=new RegExp(this.foldingStartMarker.source.replace(/\\|[^|]*?$/,\"|\"+e.start)),this.foldingStopMarker=new RegExp(this.foldingStopMarker.source.replace(/\\|[^|]*?$/,\"|\"+e.end)))};r.inherits(o,s),function(){this.foldingStartMarker=/(\\{|\\[)[^\\}\\]]*$|^\\s*(\\/\\*)/,this.foldingStopMarker=/^[^\\[\\{]*(\\}|\\])|^[\\s\\*]*(\\*\\/)/,this.singleLineBlockCommentRe=/^\\s*(\\/\\*).*\\*\\/\\s*$/,this.tripleStarBlockCommentRe=/^\\s*(\\/\\*\\*\\*).*\\*\\/\\s*$/,this.startRegionRe=/^\\s*(\\/\\*|\\/\\/)#region\\b/,this._getFoldWidgetBase=this.getFoldWidget,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);if(this.singleLineBlockCommentRe.test(r)&&!this.startRegionRe.test(r)&&!this.tripleStarBlockCommentRe.test(r))return\"\";var i=this._getFoldWidgetBase(e,t,n);return!i&&this.startRegionRe.test(r)?\"start\":i},this.getFoldWidgetRange=function(e,t,n,r){var i=e.getLine(n);if(this.startRegionRe.test(i))return this.getCommentRegionBlock(e,i,n);var s=i.match(this.foldingStartMarker);if(s){var o=s.index;if(s[1])return this.openingBracketBlock(e,s[1],n,o);var u=e.getCommentFoldRange(n,o+s[0].length,1);return u&&!u.isMultiLine()&&(r?u=this.getSectionRange(e,n):t!=\"all\"&&(u=null)),u}if(t===\"markbegin\")return;var s=i.match(this.foldingStopMarker);if(s){var o=s.index+s[0].length;return s[1]?this.closingBracketBlock(e,s[1],n,o):e.getCommentFoldRange(n,o,-1)}},this.getSectionRange=function(e,t){var n=e.getLine(t),r=n.search(/\\S/),s=t,o=n.length;t+=1;var u=t,a=e.getLength();while(++t<a){n=e.getLine(t);var f=n.search(/\\S/);if(f===-1)continue;if(r>f)break;var l=this.getFoldWidgetRange(e,\"all\",t);if(l){if(l.start.row<=s)break;if(l.isMultiLine())t=l.end.row;else if(r==f)break}u=t}return new i(s,o,u,e.getLine(u).length)},this.getCommentRegionBlock=function(e,t,n){var r=t.search(/\\s*$/),s=e.getLength(),o=n,u=/^\\s*(?:\\/\\*|\\/\\/)#(end)?region\\b/,a=1;while(++n<s){t=e.getLine(n);var f=u.exec(t);if(!f)continue;f[1]?a--:a++;if(!a)break}var l=n;if(l>o)return new i(o,r,l,t.length)}}.call(o.prototype)}),define(\"ace/mode/javascript\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/text\",\"ace/mode/javascript_highlight_rules\",\"ace/mode/matching_brace_outdent\",\"ace/range\",\"ace/worker/worker_client\",\"ace/mode/behaviour/cstyle\",\"ace/mode/folding/cstyle\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./text\").Mode,s=e(\"./javascript_highlight_rules\").JavaScriptHighlightRules,o=e(\"./matching_brace_outdent\").MatchingBraceOutdent,u=e(\"../range\").Range,a=e(\"../worker/worker_client\").WorkerClient,f=e(\"./behaviour/cstyle\").CstyleBehaviour,l=e(\"./folding/cstyle\").FoldMode,c=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new f,this.foldingRules=new l};r.inherits(c,i),function(){this.lineCommentStart=\"//\",this.blockComment={start:\"/*\",end:\"*/\"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e),s=i.tokens,o=i.state;if(s.length&&s[s.length-1].type==\"comment\")return r;if(e==\"start\"||e==\"no_regex\"){var u=t.match(/^.*(?:\\bcase\\b.*\\:|[\\{\\(\\[])\\s*$/);u&&(r+=n)}else if(e==\"doc-start\"){if(o==\"start\"||o==\"no_regex\")return\"\";var u=t.match(/^\\s*(\\/?)\\*/);u&&(u[1]&&(r+=\" \"),r+=\"* \")}return r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.createWorker=function(e){var t=new a([\"ace\"],\"ace/mode/javascript_worker\",\"JavaScriptWorker\");return t.attachToDocument(e.getDocument()),t.on(\"annotate\",function(t){e.setAnnotations(t.data)}),t.on(\"terminate\",function(){e.clearAnnotations()}),t},this.$id=\"ace/mode/javascript\"}.call(c.prototype),t.Mode=c}),define(\"ace/mode/css_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/mode/text_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/lang\"),s=e(\"./text_highlight_rules\").TextHighlightRules,o=t.supportType=\"animation-fill-mode|alignment-adjust|alignment-baseline|animation-delay|animation-direction|animation-duration|animation-iteration-count|animation-name|animation-play-state|animation-timing-function|animation|appearance|azimuth|backface-visibility|background-attachment|background-break|background-clip|background-color|background-image|background-origin|background-position|background-repeat|background-size|background|baseline-shift|binding|bleed|bookmark-label|bookmark-level|bookmark-state|bookmark-target|border-bottom|border-bottom-color|border-bottom-left-radius|border-bottom-right-radius|border-bottom-style|border-bottom-width|border-collapse|border-color|border-image|border-image-outset|border-image-repeat|border-image-slice|border-image-source|border-image-width|border-left|border-left-color|border-left-style|border-left-width|border-radius|border-right|border-right-color|border-right-style|border-right-width|border-spacing|border-style|border-top|border-top-color|border-top-left-radius|border-top-right-radius|border-top-style|border-top-width|border-width|border|bottom|box-align|box-decoration-break|box-direction|box-flex-group|box-flex|box-lines|box-ordinal-group|box-orient|box-pack|box-shadow|box-sizing|break-after|break-before|break-inside|caption-side|clear|clip|color-profile|color|column-count|column-fill|column-gap|column-rule|column-rule-color|column-rule-style|column-rule-width|column-span|column-width|columns|content|counter-increment|counter-reset|crop|cue-after|cue-before|cue|cursor|direction|display|dominant-baseline|drop-initial-after-adjust|drop-initial-after-align|drop-initial-before-adjust|drop-initial-before-align|drop-initial-size|drop-initial-value|elevation|empty-cells|fit|fit-position|float-offset|float|font-family|font-size|font-size-adjust|font-stretch|font-style|font-variant|font-weight|font|grid-columns|grid-rows|hanging-punctuation|height|hyphenate-after|hyphenate-before|hyphenate-character|hyphenate-lines|hyphenate-resource|hyphens|icon|image-orientation|image-rendering|image-resolution|inline-box-align|left|letter-spacing|line-height|line-stacking-ruby|line-stacking-shift|line-stacking-strategy|line-stacking|list-style-image|list-style-position|list-style-type|list-style|margin-bottom|margin-left|margin-right|margin-top|margin|mark-after|mark-before|mark|marks|marquee-direction|marquee-play-count|marquee-speed|marquee-style|max-height|max-width|min-height|min-width|move-to|nav-down|nav-index|nav-left|nav-right|nav-up|opacity|orphans|outline-color|outline-offset|outline-style|outline-width|outline|overflow-style|overflow-x|overflow-y|overflow|padding-bottom|padding-left|padding-right|padding-top|padding|page-break-after|page-break-before|page-break-inside|page-policy|page|pause-after|pause-before|pause|perspective-origin|perspective|phonemes|pitch-range|pitch|play-during|pointer-events|position|presentation-level|punctuation-trim|quotes|rendering-intent|resize|rest-after|rest-before|rest|richness|right|rotation-point|rotation|ruby-align|ruby-overhang|ruby-position|ruby-span|size|speak-header|speak-numeral|speak-punctuation|speak|speech-rate|stress|string-set|table-layout|target-name|target-new|target-position|target|text-align-last|text-align|text-decoration|text-emphasis|text-height|text-indent|text-justify|text-outline|text-shadow|text-transform|text-wrap|top|transform-origin|transform-style|transform|transition-delay|transition-duration|transition-property|transition-timing-function|transition|unicode-bidi|vertical-align|visibility|voice-balance|voice-duration|voice-family|voice-pitch-range|voice-pitch|voice-rate|voice-stress|voice-volume|volume|white-space-collapse|white-space|widows|width|word-break|word-spacing|word-wrap|z-index\",u=t.supportFunction=\"rgb|rgba|url|attr|counter|counters\",a=t.supportConstant=\"absolute|after-edge|after|all-scroll|all|alphabetic|always|antialiased|armenian|auto|avoid-column|avoid-page|avoid|balance|baseline|before-edge|before|below|bidi-override|block-line-height|block|bold|bolder|border-box|both|bottom|box|break-all|break-word|capitalize|caps-height|caption|center|central|char|circle|cjk-ideographic|clone|close-quote|col-resize|collapse|column|consider-shifts|contain|content-box|cover|crosshair|cubic-bezier|dashed|decimal-leading-zero|decimal|default|disabled|disc|disregard-shifts|distribute-all-lines|distribute-letter|distribute-space|distribute|dotted|double|e-resize|ease-in|ease-in-out|ease-out|ease|ellipsis|end|exclude-ruby|fill|fixed|georgian|glyphs|grid-height|groove|hand|hanging|hebrew|help|hidden|hiragana-iroha|hiragana|horizontal|icon|ideograph-alpha|ideograph-numeric|ideograph-parenthesis|ideograph-space|ideographic|inactive|include-ruby|inherit|initial|inline-block|inline-box|inline-line-height|inline-table|inline|inset|inside|inter-ideograph|inter-word|invert|italic|justify|katakana-iroha|katakana|keep-all|last|left|lighter|line-edge|line-through|line|linear|list-item|local|loose|lower-alpha|lower-greek|lower-latin|lower-roman|lowercase|lr-tb|ltr|mathematical|max-height|max-size|medium|menu|message-box|middle|move|n-resize|ne-resize|newspaper|no-change|no-close-quote|no-drop|no-open-quote|no-repeat|none|normal|not-allowed|nowrap|nw-resize|oblique|open-quote|outset|outside|overline|padding-box|page|pointer|pre-line|pre-wrap|pre|preserve-3d|progress|relative|repeat-x|repeat-y|repeat|replaced|reset-size|ridge|right|round|row-resize|rtl|s-resize|scroll|se-resize|separate|slice|small-caps|small-caption|solid|space|square|start|static|status-bar|step-end|step-start|steps|stretch|strict|sub|super|sw-resize|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table|tb-rl|text-after-edge|text-before-edge|text-bottom|text-size|text-top|text|thick|thin|transparent|underline|upper-alpha|upper-latin|upper-roman|uppercase|use-script|vertical-ideographic|vertical-text|visible|w-resize|wait|whitespace|z-index|zero\",f=t.supportConstantColor=\"aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow\",l=t.supportConstantFonts=\"arial|century|comic|courier|cursive|fantasy|garamond|georgia|helvetica|impact|lucida|symbol|system|tahoma|times|trebuchet|utopia|verdana|webdings|sans-serif|serif|monospace\",c=t.numRe=\"\\\\-?(?:(?:[0-9]+)|(?:[0-9]*\\\\.[0-9]+))\",h=t.pseudoElements=\"(\\\\:+)\\\\b(after|before|first-letter|first-line|moz-selection|selection)\\\\b\",p=t.pseudoClasses=\"(:)\\\\b(active|checked|disabled|empty|enabled|first-child|first-of-type|focus|hover|indeterminate|invalid|last-child|last-of-type|link|not|nth-child|nth-last-child|nth-last-of-type|nth-of-type|only-child|only-of-type|required|root|target|valid|visited)\\\\b\",d=function(){var e=this.createKeywordMapper({\"support.function\":u,\"support.constant\":a,\"support.type\":o,\"support.constant.color\":f,\"support.constant.fonts\":l},\"text\",!0);this.$rules={start:[{token:\"comment\",regex:\"\\\\/\\\\*\",push:\"comment\"},{token:\"paren.lparen\",regex:\"\\\\{\",push:\"ruleset\"},{token:\"string\",regex:\"@.*?{\",push:\"media\"},{token:\"keyword\",regex:\"#[a-z0-9-_]+\"},{token:\"variable\",regex:\"\\\\.[a-z0-9-_]+\"},{token:\"string\",regex:\":[a-z0-9-_]+\"},{token:\"constant\",regex:\"[a-z0-9-_]+\"},{caseInsensitive:!0}],media:[{token:\"comment\",regex:\"\\\\/\\\\*\",push:\"comment\"},{token:\"paren.lparen\",regex:\"\\\\{\",push:\"ruleset\"},{token:\"string\",regex:\"\\\\}\",next:\"pop\"},{token:\"keyword\",regex:\"#[a-z0-9-_]+\"},{token:\"variable\",regex:\"\\\\.[a-z0-9-_]+\"},{token:\"string\",regex:\":[a-z0-9-_]+\"},{token:\"constant\",regex:\"[a-z0-9-_]+\"},{caseInsensitive:!0}],comment:[{token:\"comment\",regex:\"\\\\*\\\\/\",next:\"pop\"},{defaultToken:\"comment\"}],ruleset:[{token:\"paren.rparen\",regex:\"\\\\}\",next:\"pop\"},{token:\"comment\",regex:\"\\\\/\\\\*\",push:\"comment\"},{token:\"string\",regex:'[\"](?:(?:\\\\\\\\.)|(?:[^\"\\\\\\\\]))*?[\"]'},{token:\"string\",regex:\"['](?:(?:\\\\\\\\.)|(?:[^'\\\\\\\\]))*?[']\"},{token:[\"constant.numeric\",\"keyword\"],regex:\"(\"+c+\")(ch|cm|deg|em|ex|fr|gd|grad|Hz|in|kHz|mm|ms|pc|pt|px|rad|rem|s|turn|vh|vm|vw|%)\"},{token:\"constant.numeric\",regex:c},{token:\"constant.numeric\",regex:\"#[a-f0-9]{6}\"},{token:\"constant.numeric\",regex:\"#[a-f0-9]{3}\"},{token:[\"punctuation\",\"entity.other.attribute-name.pseudo-element.css\"],regex:h},{token:[\"punctuation\",\"entity.other.attribute-name.pseudo-class.css\"],regex:p},{token:[\"support.function\",\"string\",\"support.function\"],regex:\"(url\\\\()(.*)(\\\\))\"},{token:e,regex:\"\\\\-?[a-zA-Z_][a-zA-Z0-9_\\\\-]*\"},{caseInsensitive:!0}]},this.normalizeRules()};r.inherits(d,s),t.CssHighlightRules=d}),define(\"ace/mode/behaviour/css\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/behaviour\",\"ace/mode/behaviour/cstyle\",\"ace/token_iterator\"],function(e,t,n){\"use strict\";var r=e(\"../../lib/oop\"),i=e(\"../behaviour\").Behaviour,s=e(\"./cstyle\").CstyleBehaviour,o=e(\"../../token_iterator\").TokenIterator,u=function(){this.inherit(s),this.add(\"colon\",\"insertion\",function(e,t,n,r,i){if(i===\":\"){var s=n.getCursorPosition(),u=new o(r,s.row,s.column),a=u.getCurrentToken();a&&a.value.match(/\\s+/)&&(a=u.stepBackward());if(a&&a.type===\"support.type\"){var f=r.doc.getLine(s.row),l=f.substring(s.column,s.column+1);if(l===\":\")return{text:\"\",selection:[1,1]};if(!f.substring(s.column).match(/^\\s*;/))return{text:\":;\",selection:[1,1]}}}}),this.add(\"colon\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s===\":\"){var u=n.getCursorPosition(),a=new o(r,u.row,u.column),f=a.getCurrentToken();f&&f.value.match(/\\s+/)&&(f=a.stepBackward());if(f&&f.type===\"support.type\"){var l=r.doc.getLine(i.start.row),c=l.substring(i.end.column,i.end.column+1);if(c===\";\")return i.end.column++,i}}}),this.add(\"semicolon\",\"insertion\",function(e,t,n,r,i){if(i===\";\"){var s=n.getCursorPosition(),o=r.doc.getLine(s.row),u=o.substring(s.column,s.column+1);if(u===\";\")return{text:\"\",selection:[1,1]}}})};r.inherits(u,s),t.CssBehaviour=u}),define(\"ace/mode/css\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/text\",\"ace/mode/css_highlight_rules\",\"ace/mode/matching_brace_outdent\",\"ace/worker/worker_client\",\"ace/mode/behaviour/css\",\"ace/mode/folding/cstyle\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./text\").Mode,s=e(\"./css_highlight_rules\").CssHighlightRules,o=e(\"./matching_brace_outdent\").MatchingBraceOutdent,u=e(\"../worker/worker_client\").WorkerClient,a=e(\"./behaviour/css\").CssBehaviour,f=e(\"./folding/cstyle\").FoldMode,l=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new a,this.foldingRules=new f};r.inherits(l,i),function(){this.foldingRules=\"cStyle\",this.blockComment={start:\"/*\",end:\"*/\"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e).tokens;if(i.length&&i[i.length-1].type==\"comment\")return r;var s=t.match(/^.*\\{\\s*$/);return s&&(r+=n),r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.createWorker=function(e){var t=new u([\"ace\"],\"ace/mode/css_worker\",\"Worker\");return t.attachToDocument(e.getDocument()),t.on(\"annotate\",function(t){e.setAnnotations(t.data)}),t.on(\"terminate\",function(){e.clearAnnotations()}),t},this.$id=\"ace/mode/css\"}.call(l.prototype),t.Mode=l}),define(\"ace/mode/xml_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/text_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./text_highlight_rules\").TextHighlightRules,s=function(e){var t=\"[a-zA-Z][-_a-zA-Z0-9]*\";this.$rules={start:[{token:\"string.cdata.xml\",regex:\"<\\\\!\\\\[CDATA\\\\[\",next:\"cdata\"},{token:[\"punctuation.xml-decl.xml\",\"keyword.xml-decl.xml\"],regex:\"(<\\\\?)(xml)(?=[\\\\s])\",next:\"xml_decl\",caseInsensitive:!0},{token:[\"punctuation.instruction.xml\",\"keyword.instruction.xml\"],regex:\"(<\\\\?)(\"+t+\")\",next:\"processing_instruction\"},{token:\"comment.xml\",regex:\"<\\\\!--\",next:\"comment\"},{token:[\"xml-pe.doctype.xml\",\"xml-pe.doctype.xml\"],regex:\"(<\\\\!)(DOCTYPE)(?=[\\\\s])\",next:\"doctype\",caseInsensitive:!0},{include:\"tag\"},{token:\"text.end-tag-open.xml\",regex:\"</\"},{token:\"text.tag-open.xml\",regex:\"<\"},{include:\"reference\"},{defaultToken:\"text.xml\"}],xml_decl:[{token:\"entity.other.attribute-name.decl-attribute-name.xml\",regex:\"(?:\"+t+\":)?\"+t+\"\"},{token:\"keyword.operator.decl-attribute-equals.xml\",regex:\"=\"},{include:\"whitespace\"},{include:\"string\"},{token:\"punctuation.xml-decl.xml\",regex:\"\\\\?>\",next:\"start\"}],processing_instruction:[{token:\"punctuation.instruction.xml\",regex:\"\\\\?>\",next:\"start\"},{defaultToken:\"instruction.xml\"}],doctype:[{include:\"whitespace\"},{include:\"string\"},{token:\"xml-pe.doctype.xml\",regex:\">\",next:\"start\"},{token:\"xml-pe.xml\",regex:\"[-_a-zA-Z0-9:]+\"},{token:\"punctuation.int-subset\",regex:\"\\\\[\",push:\"int_subset\"}],int_subset:[{token:\"text.xml\",regex:\"\\\\s+\"},{token:\"punctuation.int-subset.xml\",regex:\"]\",next:\"pop\"},{token:[\"punctuation.markup-decl.xml\",\"keyword.markup-decl.xml\"],regex:\"(<\\\\!)(\"+t+\")\",push:[{token:\"text\",regex:\"\\\\s+\"},{token:\"punctuation.markup-decl.xml\",regex:\">\",next:\"pop\"},{include:\"string\"}]}],cdata:[{token:\"string.cdata.xml\",regex:\"\\\\]\\\\]>\",next:\"start\"},{token:\"text.xml\",regex:\"\\\\s+\"},{token:\"text.xml\",regex:\"(?:[^\\\\]]|\\\\](?!\\\\]>))+\"}],comment:[{token:\"comment.xml\",regex:\"-->\",next:\"start\"},{defaultToken:\"comment.xml\"}],reference:[{token:\"constant.language.escape.reference.xml\",regex:\"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\\\.-]+;)\"}],attr_reference:[{token:\"constant.language.escape.reference.attribute-value.xml\",regex:\"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\\\.-]+;)\"}],tag:[{token:[\"meta.tag.punctuation.tag-open.xml\",\"meta.tag.punctuation.end-tag-open.xml\",\"meta.tag.tag-name.xml\"],regex:\"(?:(<)|(</))((?:\"+t+\":)?\"+t+\")\",next:[{include:\"attributes\"},{token:\"meta.tag.punctuation.tag-close.xml\",regex:\"/?>\",next:\"start\"}]}],tag_whitespace:[{token:\"text.tag-whitespace.xml\",regex:\"\\\\s+\"}],whitespace:[{token:\"text.whitespace.xml\",regex:\"\\\\s+\"}],string:[{token:\"string.xml\",regex:\"'\",push:[{token:\"string.xml\",regex:\"'\",next:\"pop\"},{defaultToken:\"string.xml\"}]},{token:\"string.xml\",regex:'\"',push:[{token:\"string.xml\",regex:'\"',next:\"pop\"},{defaultToken:\"string.xml\"}]}],attributes:[{token:\"entity.other.attribute-name.xml\",regex:\"(?:\"+t+\":)?\"+t+\"\"},{token:\"keyword.operator.attribute-equals.xml\",regex:\"=\"},{include:\"tag_whitespace\"},{include:\"attribute_value\"}],attribute_value:[{token:\"string.attribute-value.xml\",regex:\"'\",push:[{token:\"string.attribute-value.xml\",regex:\"'\",next:\"pop\"},{include:\"attr_reference\"},{defaultToken:\"string.attribute-value.xml\"}]},{token:\"string.attribute-value.xml\",regex:'\"',push:[{token:\"string.attribute-value.xml\",regex:'\"',next:\"pop\"},{include:\"attr_reference\"},{defaultToken:\"string.attribute-value.xml\"}]}]},this.constructor===s&&this.normalizeRules()};(function(){this.embedTagRules=function(e,t,n){this.$rules.tag.unshift({token:[\"meta.tag.punctuation.tag-open.xml\",\"meta.tag.\"+n+\".tag-name.xml\"],regex:\"(<)(\"+n+\"(?=\\\\s|>|$))\",next:[{include:\"attributes\"},{token:\"meta.tag.punctuation.tag-close.xml\",regex:\"/?>\",next:t+\"start\"}]}),this.$rules[n+\"-end\"]=[{include:\"attributes\"},{token:\"meta.tag.punctuation.tag-close.xml\",regex:\"/?>\",next:\"start\",onMatch:function(e,t,n){return n.splice(0),this.token}}],this.embedRules(e,t,[{token:[\"meta.tag.punctuation.end-tag-open.xml\",\"meta.tag.\"+n+\".tag-name.xml\"],regex:\"(</)(\"+n+\"(?=\\\\s|>|$))\",next:n+\"-end\"},{token:\"string.cdata.xml\",regex:\"<\\\\!\\\\[CDATA\\\\[\"},{token:\"string.cdata.xml\",regex:\"\\\\]\\\\]>\"}])}}).call(i.prototype),r.inherits(s,i),t.XmlHighlightRules=s}),define(\"ace/mode/html_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/mode/css_highlight_rules\",\"ace/mode/javascript_highlight_rules\",\"ace/mode/xml_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/lang\"),s=e(\"./css_highlight_rules\").CssHighlightRules,o=e(\"./javascript_highlight_rules\").JavaScriptHighlightRules,u=e(\"./xml_highlight_rules\").XmlHighlightRules,a=i.createMap({a:\"anchor\",button:\"form\",form:\"form\",img:\"image\",input:\"form\",label:\"form\",option:\"form\",script:\"script\",select:\"form\",textarea:\"form\",style:\"style\",table:\"table\",tbody:\"table\",td:\"table\",tfoot:\"table\",th:\"table\",tr:\"table\"}),f=function(){u.call(this),this.addRules({attributes:[{include:\"tag_whitespace\"},{token:\"entity.other.attribute-name.xml\",regex:\"[-_a-zA-Z0-9:]+\"},{token:\"keyword.operator.attribute-equals.xml\",regex:\"=\",push:[{include:\"tag_whitespace\"},{token:\"string.unquoted.attribute-value.html\",regex:\"[^<>='\\\"`\\\\s]+\",next:\"pop\"},{token:\"empty\",regex:\"\",next:\"pop\"}]},{include:\"attribute_value\"}],tag:[{token:function(e,t){var n=a[t];return[\"meta.tag.punctuation.\"+(e==\"<\"?\"\":\"end-\")+\"tag-open.xml\",\"meta.tag\"+(n?\".\"+n:\"\")+\".tag-name.xml\"]},regex:\"(</?)([-_a-zA-Z0-9:]+)\",next:\"tag_stuff\"}],tag_stuff:[{include:\"attributes\"},{token:\"meta.tag.punctuation.tag-close.xml\",regex:\"/?>\",next:\"start\"}]}),this.embedTagRules(s,\"css-\",\"style\"),this.embedTagRules(o,\"js-\",\"script\"),this.constructor===f&&this.normalizeRules()};r.inherits(f,u),t.HtmlHighlightRules=f}),define(\"ace/mode/behaviour/xml\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/behaviour\",\"ace/token_iterator\",\"ace/lib/lang\"],function(e,t,n){\"use strict\";function u(e,t){return e.type.lastIndexOf(t+\".xml\")>-1}var r=e(\"../../lib/oop\"),i=e(\"../behaviour\").Behaviour,s=e(\"../../token_iterator\").TokenIterator,o=e(\"../../lib/lang\"),a=function(){this.add(\"string_dquotes\",\"insertion\",function(e,t,n,r,i){if(i=='\"'||i==\"'\"){var o=i,a=r.doc.getTextRange(n.getSelectionRange());if(a!==\"\"&&a!==\"'\"&&a!='\"'&&n.getWrapBehavioursEnabled())return{text:o+a+o,selection:!1};var f=n.getCursorPosition(),l=r.doc.getLine(f.row),c=l.substring(f.column,f.column+1),h=new s(r,f.row,f.column),p=h.getCurrentToken();if(c==o&&(u(p,\"attribute-value\")||u(p,\"string\")))return{text:\"\",selection:[1,1]};p||(p=h.stepBackward());if(!p)return;while(u(p,\"tag-whitespace\")||u(p,\"whitespace\"))p=h.stepBackward();var d=!c||c.match(/\\s/);if(u(p,\"attribute-equals\")&&(d||c==\">\")||u(p,\"decl-attribute-equals\")&&(d||c==\"?\"))return{text:o+o,selection:[1,1]}}}),this.add(\"string_dquotes\",\"deletion\",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='\"'||s==\"'\")){var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}}),this.add(\"autoclosing\",\"insertion\",function(e,t,n,r,i){if(i==\">\"){var o=n.getCursorPosition(),a=new s(r,o.row,o.column),f=a.getCurrentToken()||a.stepBackward();if(!f||!(u(f,\"tag-name\")||u(f,\"tag-whitespace\")||u(f,\"attribute-name\")||u(f,\"attribute-equals\")||u(f,\"attribute-value\")))return;if(u(f,\"reference.attribute-value\"))return;if(u(f,\"attribute-value\")){var l=f.value.charAt(0);if(l=='\"'||l==\"'\"){var c=f.value.charAt(f.value.length-1),h=a.getCurrentTokenColumn()+f.value.length;if(h>o.column||h==o.column&&l!=c)return}}while(!u(f,\"tag-name\"))f=a.stepBackward();var p=a.getCurrentTokenRow(),d=a.getCurrentTokenColumn();if(u(a.stepBackward(),\"end-tag-open\"))return;var v=f.value;p==o.row&&(v=v.substring(0,o.column-d));if(this.voidElements.hasOwnProperty(v.toLowerCase()))return;return{text:\"></\"+v+\">\",selection:[1,1]}}}),this.add(\"autoindent\",\"insertion\",function(e,t,n,r,i){if(i==\"\\n\"){var o=n.getCursorPosition(),u=r.getLine(o.row),a=new s(r,o.row,o.column),f=a.getCurrentToken();if(f&&f.type.indexOf(\"tag-close\")!==-1){if(f.value==\"/>\")return;while(f&&f.type.indexOf(\"tag-name\")===-1)f=a.stepBackward();if(!f)return;var l=f.value,c=a.getCurrentTokenRow();f=a.stepBackward();if(!f||f.type.indexOf(\"end-tag\")!==-1)return;if(this.voidElements&&!this.voidElements[l]){var h=r.getTokenAt(o.row,o.column+1),u=r.getLine(c),p=this.$getIndent(u),d=p+r.getTabString();return h&&h.value===\"</\"?{text:\"\\n\"+d+\"\\n\"+p,selection:[1,d.length,1,d.length]}:{text:\"\\n\"+d}}}}})};r.inherits(a,i),t.XmlBehaviour=a}),define(\"ace/mode/folding/mixed\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/folding/fold_mode\"],function(e,t,n){\"use strict\";var r=e(\"../../lib/oop\"),i=e(\"./fold_mode\").FoldMode,s=t.FoldMode=function(e,t){this.defaultMode=e,this.subModes=t};r.inherits(s,i),function(){this.$getMode=function(e){typeof e!=\"string\"&&(e=e[0]);for(var t in this.subModes)if(e.indexOf(t)===0)return this.subModes[t];return null},this.$tryMode=function(e,t,n,r){var i=this.$getMode(e);return i?i.getFoldWidget(t,n,r):\"\"},this.getFoldWidget=function(e,t,n){return this.$tryMode(e.getState(n-1),e,t,n)||this.$tryMode(e.getState(n),e,t,n)||this.defaultMode.getFoldWidget(e,t,n)},this.getFoldWidgetRange=function(e,t,n){var r=this.$getMode(e.getState(n-1));if(!r||!r.getFoldWidget(e,t,n))r=this.$getMode(e.getState(n));if(!r||!r.getFoldWidget(e,t,n))r=this.defaultMode;return r.getFoldWidgetRange(e,t,n)}}.call(s.prototype)}),define(\"ace/mode/folding/xml\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/range\",\"ace/mode/folding/fold_mode\",\"ace/token_iterator\"],function(e,t,n){\"use strict\";function l(e,t){return e.type.lastIndexOf(t+\".xml\")>-1}var r=e(\"../../lib/oop\"),i=e(\"../../lib/lang\"),s=e(\"../../range\").Range,o=e(\"./fold_mode\").FoldMode,u=e(\"../../token_iterator\").TokenIterator,a=t.FoldMode=function(e,t){o.call(this),this.voidElements=e||{},this.optionalEndTags=r.mixin({},this.voidElements),t&&r.mixin(this.optionalEndTags,t)};r.inherits(a,o);var f=function(){this.tagName=\"\",this.closing=!1,this.selfClosing=!1,this.start={row:0,column:0},this.end={row:0,column:0}};(function(){this.getFoldWidget=function(e,t,n){var r=this._getFirstTagInLine(e,n);return r?r.closing||!r.tagName&&r.selfClosing?t==\"markbeginend\"?\"end\":\"\":!r.tagName||r.selfClosing||this.voidElements.hasOwnProperty(r.tagName.toLowerCase())?\"\":this._findEndTagInLine(e,n,r.tagName,r.end.column)?\"\":\"start\":\"\"},this._getFirstTagInLine=function(e,t){var n=e.getTokens(t),r=new f;for(var i=0;i<n.length;i++){var s=n[i];if(l(s,\"tag-open\")){r.end.column=r.start.column+s.value.length,r.closing=l(s,\"end-tag-open\"),s=n[++i];if(!s)return null;r.tagName=s.value,r.end.column+=s.value.length;for(i++;i<n.length;i++){s=n[i],r.end.column+=s.value.length;if(l(s,\"tag-close\")){r.selfClosing=s.value==\"/>\";break}}return r}if(l(s,\"tag-close\"))return r.selfClosing=s.value==\"/>\",r;r.start.column+=s.value.length}return null},this._findEndTagInLine=function(e,t,n,r){var i=e.getTokens(t),s=0;for(var o=0;o<i.length;o++){var u=i[o];s+=u.value.length;if(s<r)continue;if(l(u,\"end-tag-open\")){u=i[o+1];if(u&&u.value==n)return!0}}return!1},this._readTagForward=function(e){var t=e.getCurrentToken();if(!t)return null;var n=new f;do if(l(t,\"tag-open\"))n.closing=l(t,\"end-tag-open\"),n.start.row=e.getCurrentTokenRow(),n.start.column=e.getCurrentTokenColumn();else if(l(t,\"tag-name\"))n.tagName=t.value;else if(l(t,\"tag-close\"))return n.selfClosing=t.value==\"/>\",n.end.row=e.getCurrentTokenRow(),n.end.column=e.getCurrentTokenColumn()+t.value.length,e.stepForward(),n;while(t=e.stepForward());return null},this._readTagBackward=function(e){var t=e.getCurrentToken();if(!t)return null;var n=new f;do{if(l(t,\"tag-open\"))return n.closing=l(t,\"end-tag-open\"),n.start.row=e.getCurrentTokenRow(),n.start.column=e.getCurrentTokenColumn(),e.stepBackward(),n;l(t,\"tag-name\")?n.tagName=t.value:l(t,\"tag-close\")&&(n.selfClosing=t.value==\"/>\",n.end.row=e.getCurrentTokenRow(),n.end.column=e.getCurrentTokenColumn()+t.value.length)}while(t=e.stepBackward());return null},this._pop=function(e,t){while(e.length){var n=e[e.length-1];if(!t||n.tagName==t.tagName)return e.pop();if(this.optionalEndTags.hasOwnProperty(n.tagName)){e.pop();continue}return null}},this.getFoldWidgetRange=function(e,t,n){var r=this._getFirstTagInLine(e,n);if(!r)return null;var i=r.closing||r.selfClosing,o=[],a;if(!i){var f=new u(e,n,r.start.column),l={row:n,column:r.start.column+r.tagName.length+2};r.start.row==r.end.row&&(l.column=r.end.column);while(a=this._readTagForward(f)){if(a.selfClosing){if(!o.length)return a.start.column+=a.tagName.length+2,a.end.column-=2,s.fromPoints(a.start,a.end);continue}if(a.closing){this._pop(o,a);if(o.length==0)return s.fromPoints(l,a.start)}else o.push(a)}}else{var f=new u(e,n,r.end.column),c={row:n,column:r.start.column};while(a=this._readTagBackward(f)){if(a.selfClosing){if(!o.length)return a.start.column+=a.tagName.length+2,a.end.column-=2,s.fromPoints(a.start,a.end);continue}if(!a.closing){this._pop(o,a);if(o.length==0)return a.start.column+=a.tagName.length+2,a.start.row==a.end.row&&a.start.column<a.end.column&&(a.start.column=a.end.column),s.fromPoints(a.start,c)}else o.push(a)}}}}).call(a.prototype)}),define(\"ace/mode/folding/html\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/folding/mixed\",\"ace/mode/folding/xml\",\"ace/mode/folding/cstyle\"],function(e,t,n){\"use strict\";var r=e(\"../../lib/oop\"),i=e(\"./mixed\").FoldMode,s=e(\"./xml\").FoldMode,o=e(\"./cstyle\").FoldMode,u=t.FoldMode=function(e,t){i.call(this,new s(e,t),{\"js-\":new o,\"css-\":new o})};r.inherits(u,i)}),define(\"ace/mode/html_completions\",[\"require\",\"exports\",\"module\",\"ace/token_iterator\"],function(e,t,n){\"use strict\";function f(e,t){return e.type.lastIndexOf(t+\".xml\")>-1}function l(e,t){var n=new r(e,t.row,t.column),i=n.getCurrentToken();while(i&&!f(i,\"tag-name\"))i=n.stepBackward();if(i)return i.value}var r=e(\"../token_iterator\").TokenIterator,i=[\"accesskey\",\"class\",\"contenteditable\",\"contextmenu\",\"dir\",\"draggable\",\"dropzone\",\"hidden\",\"id\",\"inert\",\"itemid\",\"itemprop\",\"itemref\",\"itemscope\",\"itemtype\",\"lang\",\"spellcheck\",\"style\",\"tabindex\",\"title\",\"translate\"],s=[\"onabort\",\"onblur\",\"oncancel\",\"oncanplay\",\"oncanplaythrough\",\"onchange\",\"onclick\",\"onclose\",\"oncontextmenu\",\"oncuechange\",\"ondblclick\",\"ondrag\",\"ondragend\",\"ondragenter\",\"ondragleave\",\"ondragover\",\"ondragstart\",\"ondrop\",\"ondurationchange\",\"onemptied\",\"onended\",\"onerror\",\"onfocus\",\"oninput\",\"oninvalid\",\"onkeydown\",\"onkeypress\",\"onkeyup\",\"onload\",\"onloadeddata\",\"onloadedmetadata\",\"onloadstart\",\"onmousedown\",\"onmousemove\",\"onmouseout\",\"onmouseover\",\"onmouseup\",\"onmousewheel\",\"onpause\",\"onplay\",\"onplaying\",\"onprogress\",\"onratechange\",\"onreset\",\"onscroll\",\"onseeked\",\"onseeking\",\"onselect\",\"onshow\",\"onstalled\",\"onsubmit\",\"onsuspend\",\"ontimeupdate\",\"onvolumechange\",\"onwaiting\"],o=i.concat(s),u={html:[\"manifest\"],head:[],title:[],base:[\"href\",\"target\"],link:[\"href\",\"hreflang\",\"rel\",\"media\",\"type\",\"sizes\"],meta:[\"http-equiv\",\"name\",\"content\",\"charset\"],style:[\"type\",\"media\",\"scoped\"],script:[\"charset\",\"type\",\"src\",\"defer\",\"async\"],noscript:[\"href\"],body:[\"onafterprint\",\"onbeforeprint\",\"onbeforeunload\",\"onhashchange\",\"onmessage\",\"onoffline\",\"onpopstate\",\"onredo\",\"onresize\",\"onstorage\",\"onundo\",\"onunload\"],section:[],nav:[],article:[\"pubdate\"],aside:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],header:[],footer:[],address:[],main:[],p:[],hr:[],pre:[],blockquote:[\"cite\"],ol:[\"start\",\"reversed\"],ul:[],li:[\"value\"],dl:[],dt:[],dd:[],figure:[],figcaption:[],div:[],a:[\"href\",\"target\",\"ping\",\"rel\",\"media\",\"hreflang\",\"type\"],em:[],strong:[],small:[],s:[],cite:[],q:[\"cite\"],dfn:[],abbr:[],data:[],time:[\"datetime\"],code:[],\"var\":[],samp:[],kbd:[],sub:[],sup:[],i:[],b:[],u:[],mark:[],ruby:[],rt:[],rp:[],bdi:[],bdo:[],span:[],br:[],wbr:[],ins:[\"cite\",\"datetime\"],del:[\"cite\",\"datetime\"],img:[\"alt\",\"src\",\"height\",\"width\",\"usemap\",\"ismap\"],iframe:[\"name\",\"src\",\"height\",\"width\",\"sandbox\",\"seamless\"],embed:[\"src\",\"height\",\"width\",\"type\"],object:[\"param\",\"data\",\"type\",\"height\",\"width\",\"usemap\",\"name\",\"form\",\"classid\"],param:[\"name\",\"value\"],video:[\"src\",\"autobuffer\",\"autoplay\",\"loop\",\"controls\",\"width\",\"height\",\"poster\"],audio:[\"src\",\"autobuffer\",\"autoplay\",\"loop\",\"controls\"],source:[\"src\",\"type\",\"media\"],track:[\"kind\",\"src\",\"srclang\",\"label\",\"default\"],canvas:[\"width\",\"height\"],map:[\"name\"],area:[\"shape\",\"coords\",\"href\",\"hreflang\",\"alt\",\"target\",\"media\",\"rel\",\"ping\",\"type\"],svg:[],math:[],table:[\"summary\"],caption:[],colgroup:[\"span\"],col:[\"span\"],tbody:[],thead:[],tfoot:[],tr:[],td:[\"headers\",\"rowspan\",\"colspan\"],th:[\"headers\",\"rowspan\",\"colspan\",\"scope\"],form:[\"accept-charset\",\"action\",\"autocomplete\",\"enctype\",\"method\",\"name\",\"novalidate\",\"target\"],fieldset:[\"disabled\",\"form\",\"name\"],legend:[],label:[\"form\",\"for\"],input:[\"type\",\"accept\",\"alt\",\"autocomplete\",\"checked\",\"disabled\",\"form\",\"formaction\",\"formenctype\",\"formmethod\",\"formnovalidate\",\"formtarget\",\"height\",\"list\",\"max\",\"maxlength\",\"min\",\"multiple\",\"pattern\",\"placeholder\",\"readonly\",\"required\",\"size\",\"src\",\"step\",\"width\",\"files\",\"value\"],button:[\"autofocus\",\"disabled\",\"form\",\"formaction\",\"formenctype\",\"formmethod\",\"formnovalidate\",\"formtarget\",\"name\",\"value\",\"type\"],select:[\"autofocus\",\"disabled\",\"form\",\"multiple\",\"name\",\"size\"],datalist:[],optgroup:[\"disabled\",\"label\"],option:[\"disabled\",\"selected\",\"label\",\"value\"],textarea:[\"autofocus\",\"disabled\",\"form\",\"maxlength\",\"name\",\"placeholder\",\"readonly\",\"required\",\"rows\",\"cols\",\"wrap\"],keygen:[\"autofocus\",\"challenge\",\"disabled\",\"form\",\"keytype\",\"name\"],output:[\"for\",\"form\",\"name\"],progress:[\"value\",\"max\"],meter:[\"value\",\"min\",\"max\",\"low\",\"high\",\"optimum\"],details:[\"open\"],summary:[],command:[\"type\",\"label\",\"icon\",\"disabled\",\"checked\",\"radiogroup\",\"command\"],menu:[\"type\",\"label\"],dialog:[\"open\"]},a=Object.keys(u),c=function(){};(function(){this.getCompletions=function(e,t,n,r){var i=t.getTokenAt(n.row,n.column);return i?f(i,\"tag-name\")||f(i,\"tag-open\")||f(i,\"end-tag-open\")?this.getTagCompletions(e,t,n,r):f(i,\"tag-whitespace\")||f(i,\"attribute-name\")?this.getAttributeCompetions(e,t,n,r):[]:[]},this.getTagCompletions=function(e,t,n,r){return a.map(function(e){return{value:e,meta:\"tag\",score:Number.MAX_VALUE}})},this.getAttributeCompetions=function(e,t,n,r){var i=l(t,n);if(!i)return[];var s=o;return i in u&&(s=s.concat(u[i])),s.map(function(e){return{caption:e,snippet:e+'=\"$0\"',meta:\"attribute\",score:Number.MAX_VALUE}})}}).call(c.prototype),t.HtmlCompletions=c}),define(\"ace/mode/html\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/mode/text\",\"ace/mode/javascript\",\"ace/mode/css\",\"ace/mode/html_highlight_rules\",\"ace/mode/behaviour/xml\",\"ace/mode/folding/html\",\"ace/mode/html_completions\",\"ace/worker/worker_client\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/lang\"),s=e(\"./text\").Mode,o=e(\"./javascript\").Mode,u=e(\"./css\").Mode,a=e(\"./html_highlight_rules\").HtmlHighlightRules,f=e(\"./behaviour/xml\").XmlBehaviour,l=e(\"./folding/html\").FoldMode,c=e(\"./html_completions\").HtmlCompletions,h=e(\"../worker/worker_client\").WorkerClient,p=[\"area\",\"base\",\"br\",\"col\",\"embed\",\"hr\",\"img\",\"input\",\"keygen\",\"link\",\"meta\",\"menuitem\",\"param\",\"source\",\"track\",\"wbr\"],d=[\"li\",\"dt\",\"dd\",\"p\",\"rt\",\"rp\",\"optgroup\",\"option\",\"colgroup\",\"td\",\"th\"],v=function(e){this.fragmentContext=e&&e.fragmentContext,this.HighlightRules=a,this.$behaviour=new f,this.$completer=new c,this.createModeDelegates({\"js-\":o,\"css-\":u}),this.foldingRules=new l(this.voidElements,i.arrayToMap(d))};r.inherits(v,s),function(){this.blockComment={start:\"<!--\",end:\"-->\"},this.voidElements=i.arrayToMap(p),this.getNextLineIndent=function(e,t,n){return this.$getIndent(t)},this.checkOutdent=function(e,t,n){return!1},this.getCompletions=function(e,t,n,r){return this.$completer.getCompletions(e,t,n,r)},this.createWorker=function(e){if(this.constructor!=v)return;var t=new h([\"ace\"],\"ace/mode/html_worker\",\"Worker\");return t.attachToDocument(e.getDocument()),this.fragmentContext&&t.call(\"setOptions\",[{context:this.fragmentContext}]),t.on(\"error\",function(t){e.setAnnotations(t.data)}),t.on(\"terminate\",function(){e.clearAnnotations()}),t},this.$id=\"ace/mode/html\"}.call(v.prototype),t.Mode=v}),define(\"ace/mode/twig_highlight_rules\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/lib/lang\",\"ace/mode/html_highlight_rules\",\"ace/mode/text_highlight_rules\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"../lib/lang\"),s=e(\"./html_highlight_rules\").HtmlHighlightRules,o=e(\"./text_highlight_rules\").TextHighlightRules,u=function(){s.call(this);var e=\"autoescape|block|do|embed|extends|filter|flush|for|from|if|import|include|macro|sandbox|set|spaceless|use|verbatim\";e=e+\"|end\"+e.replace(/\\|/g,\"|end\");var t=\"abs|batch|capitalize|convert_encoding|date|date_modify|default|e|escape|first|format|join|json_encode|keys|last|length|lower|merge|nl2br|number_format|raw|replace|reverse|slice|sort|split|striptags|title|trim|upper|url_encode\",n=\"attribute|constant|cycle|date|dump|parent|random|range|template_from_string\",r=\"constant|divisibleby|sameas|defined|empty|even|iterable|odd\",i=\"null|none|true|false\",o=\"b-and|b-xor|b-or|in|is|and|or|not\",u=this.createKeywordMapper({\"keyword.control.twig\":e,\"support.function.twig\":[t,n,r].join(\"|\"),\"keyword.operator.twig\":o,\"constant.language.twig\":i},\"identifier\");for(var a in this.$rules)this.$rules[a].unshift({token:\"variable.other.readwrite.local.twig\",regex:\"\\\\{\\\\{-?\",push:\"twig-start\"},{token:\"meta.tag.twig\",regex:\"\\\\{%-?\",push:\"twig-start\"},{token:\"comment.block.twig\",regex:\"\\\\{#-?\",push:\"twig-comment\"});this.$rules[\"twig-comment\"]=[{token:\"comment.block.twig\",regex:\".*-?#\\\\}\",next:\"pop\"}],this.$rules[\"twig-start\"]=[{token:\"variable.other.readwrite.local.twig\",regex:\"-?\\\\}\\\\}\",next:\"pop\"},{token:\"meta.tag.twig\",regex:\"-?%\\\\}\",next:\"pop\"},{token:\"string\",regex:\"'\",next:\"twig-qstring\"},{token:\"string\",regex:'\"',next:\"twig-qqstring\"},{token:\"constant.numeric\",regex:\"0[xX][0-9a-fA-F]+\\\\b\"},{token:\"constant.numeric\",regex:\"[+-]?\\\\d+(?:(?:\\\\.\\\\d*)?(?:[eE][+-]?\\\\d+)?)?\\\\b\"},{token:\"constant.language.boolean\",regex:\"(?:true|false)\\\\b\"},{token:u,regex:\"[a-zA-Z_$][a-zA-Z0-9_$]*\\\\b\"},{token:\"keyword.operator.assignment\",regex:\"=|~\"},{token:\"keyword.operator.comparison\",regex:\"==|!=|<|>|>=|<=|===\"},{token:\"keyword.operator.arithmetic\",regex:\"\\\\+|-|/|%|//|\\\\*|\\\\*\\\\*\"},{token:\"keyword.operator.other\",regex:\"\\\\.\\\\.|\\\\|\"},{token:\"punctuation.operator\",regex:/\\?|\\:|\\,|\\;|\\./},{token:\"paren.lparen\",regex:/[\\[\\({]/},{token:\"paren.rparen\",regex:/[\\])}]/},{token:\"text\",regex:\"\\\\s+\"}],this.$rules[\"twig-qqstring\"]=[{token:\"constant.language.escape\",regex:/\\\\[\\\\\"$#ntr]|#{[^\"}]*}/},{token:\"string\",regex:'\"',next:\"twig-start\"},{defaultToken:\"string\"}],this.$rules[\"twig-qstring\"]=[{token:\"constant.language.escape\",regex:/\\\\[\\\\'ntr]}/},{token:\"string\",regex:\"'\",next:\"twig-start\"},{defaultToken:\"string\"}],this.normalizeRules()};r.inherits(u,o),t.TwigHighlightRules=u}),define(\"ace/mode/twig\",[\"require\",\"exports\",\"module\",\"ace/lib/oop\",\"ace/mode/html\",\"ace/mode/twig_highlight_rules\",\"ace/mode/matching_brace_outdent\"],function(e,t,n){\"use strict\";var r=e(\"../lib/oop\"),i=e(\"./html\").Mode,s=e(\"./twig_highlight_rules\").TwigHighlightRules,o=e(\"./matching_brace_outdent\").MatchingBraceOutdent,u=function(){i.call(this),this.HighlightRules=s,this.$outdent=new o};r.inherits(u,i),function(){this.blockComment={start:\"{#\",end:\"#}\"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e),s=i.tokens,o=i.state;if(s.length&&s[s.length-1].type==\"comment\")return r;if(e==\"start\"){var u=t.match(/^.*[\\{\\(\\[]\\s*$/);u&&(r+=n)}return r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.$id=\"ace/mode/twig\"}.call(u.prototype),t.Mode=u})"
  },
  {
    "path": "js/admin/ace/theme-chrome.js",
    "content": "define(\"ace/theme/chrome\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\"],function(e,t,n){t.isDark=!1,t.cssClass=\"ace-chrome\",t.cssText='.ace-chrome .ace_gutter {background: #ebebeb;color: #333;overflow : hidden;}.ace-chrome .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-chrome {background-color: #FFFFFF;color: black;}.ace-chrome .ace_cursor {color: black;}.ace-chrome .ace_invisible {color: rgb(191, 191, 191);}.ace-chrome .ace_constant.ace_buildin {color: rgb(88, 72, 246);}.ace-chrome .ace_constant.ace_language {color: rgb(88, 92, 246);}.ace-chrome .ace_constant.ace_library {color: rgb(6, 150, 14);}.ace-chrome .ace_invalid {background-color: rgb(153, 0, 0);color: white;}.ace-chrome .ace_fold {}.ace-chrome .ace_support.ace_function {color: rgb(60, 76, 114);}.ace-chrome .ace_support.ace_constant {color: rgb(6, 150, 14);}.ace-chrome .ace_support.ace_type,.ace-chrome .ace_support.ace_class.ace-chrome .ace_support.ace_other {color: rgb(109, 121, 222);}.ace-chrome .ace_variable.ace_parameter {font-style:italic;color:#FD971F;}.ace-chrome .ace_keyword.ace_operator {color: rgb(104, 118, 135);}.ace-chrome .ace_comment {color: #236e24;}.ace-chrome .ace_comment.ace_doc {color: #236e24;}.ace-chrome .ace_comment.ace_doc.ace_tag {color: #236e24;}.ace-chrome .ace_constant.ace_numeric {color: rgb(0, 0, 205);}.ace-chrome .ace_variable {color: rgb(49, 132, 149);}.ace-chrome .ace_xml-pe {color: rgb(104, 104, 91);}.ace-chrome .ace_entity.ace_name.ace_function {color: #0000A2;}.ace-chrome .ace_heading {color: rgb(12, 7, 255);}.ace-chrome .ace_list {color:rgb(185, 6, 144);}.ace-chrome .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-chrome .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-chrome .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-chrome .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-chrome .ace_marker-layer .ace_active-line {background: rgba(0, 0, 0, 0.07);}.ace-chrome .ace_gutter-active-line {background-color : #dcdcdc;}.ace-chrome .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-chrome .ace_storage,.ace-chrome .ace_keyword,.ace-chrome .ace_meta.ace_tag {color: rgb(147, 15, 128);}.ace-chrome .ace_string.ace_regex {color: rgb(255, 0, 0)}.ace-chrome .ace_string {color: #1A1AA6;}.ace-chrome .ace_entity.ace_other.ace_attribute-name {color: #994409;}.ace-chrome .ace_indent-guide {background: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==\") right repeat-y;}';var r=e(\"../lib/dom\");r.importCssString(t.cssText,t.cssClass)})"
  },
  {
    "path": "js/admin/ace/theme-github.js",
    "content": "define(\"ace/theme/github\",[\"require\",\"exports\",\"module\",\"ace/lib/dom\"],function(e,t,n){t.isDark=!1,t.cssClass=\"ace-github\",t.cssText='.ace-github .ace_gutter {background: #e8e8e8;color: #AAA;}.ace-github  {background: #fff;color: #000;}.ace-github .ace_keyword {font-weight: bold;}.ace-github .ace_string {color: #D14;}.ace-github .ace_variable.ace_class {color: teal;}.ace-github .ace_constant.ace_numeric {color: #099;}.ace-github .ace_constant.ace_buildin {color: #0086B3;}.ace-github .ace_support.ace_function {color: #0086B3;}.ace-github .ace_comment {color: #998;font-style: italic;}.ace-github .ace_variable.ace_language  {color: #0086B3;}.ace-github .ace_paren {font-weight: bold;}.ace-github .ace_boolean {font-weight: bold;}.ace-github .ace_string.ace_regexp {color: #009926;font-weight: normal;}.ace-github .ace_variable.ace_instance {color: teal;}.ace-github .ace_constant.ace_language {font-weight: bold;}.ace-github .ace_cursor {color: black;}.ace-github .ace_marker-layer .ace_active-line {background: rgb(255, 255, 204);}.ace-github .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-github.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px white;border-radius: 2px;}.ace-github.ace_nobold .ace_line > span {font-weight: normal !important;}.ace-github .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-github .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-github .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-github .ace_gutter-active-line {background-color : rgba(0, 0, 0, 0.07);}.ace-github .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-github .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-github .ace_indent-guide {background: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==\") right repeat-y;}';var r=e(\"../lib/dom\");r.importCssString(t.cssText,t.cssClass)})"
  },
  {
    "path": "js/admin/chosen/chosenImage.css",
    "content": "/* Give the image 3px of space to breathe. */\n.chosenImage-container .chosen-results li,\n.chosenImage-container .chosen-single span {\n  background: none 3px center / 19px 19px no-repeat;\n  padding-left: 28px;\n}\n\n/* Make the image fit nicely to the left of the dropdown. */\n.chosenImage-container .chosen-single {\n  padding-left: 2px;\n}\n\n.chosenImage-container .chosen-single span {\n  background-position: left 2px;\n}\n\n/* Let the \"No results match\" text fill the whole width. */\n.chosenImage-container .chosen-results .no-results {\n  padding-left: inherit;\n}\n"
  },
  {
    "path": "js/admin/chosen/chosenImage.jquery.js",
    "content": "/*\n * Chosen jQuery plugin to add an image to the dropdown items.\n */\n(function($) {\n    $.fn.chosenImage = function(options) {\n        return this.each(function() {\n            var $select = $(this);\n            var imgMap  = {};\n\n            // 1. Retrieve img-src from data attribute and build object of image sources for each list item.\n            $select.find('option').filter(function(){\n                return $(this).text();\n            }).each(function(i) {\n                imgMap[i] = $(this).attr('data-img-src');\n            });\n\n            // 2. Execute chosen plugin and get the newly created chosen container.\n            $select.chosen(options);\n            var $chosen = $select.next('.chosen-container').addClass('chosenImage-container');\n\n            // 3. Style lis with image sources.\n            $chosen.on('mousedown.chosen, keyup.chosen', function(event){\n                $chosen.find('.chosen-results li').each(function() {\n                    var imgIndex = $(this).attr('data-option-array-index');\n                    $(this).css(cssObj(imgMap[imgIndex]));\n                });\n            });\n\n            // 4. Change image on chosen selected element when form changes.\n            $select.change(function() {\n                var imgSrc = $select.find('option:selected').attr('data-img-src') || '';\n                $chosen.find('.chosen-single span').css(cssObj(imgSrc));\n            });\n            $select.trigger('change');\n\n            // Utilties\n            function cssObj(imgSrc) {\n                var bgImg = (imgSrc) ? 'url(' + imgSrc + ')' : 'none';\n                return { 'background-image' : bgImg };\n            }\n        });\n    };\n})(jQuery);\n"
  },
  {
    "path": "js/admin/cornify.js",
    "content": "var cornify_count = 0;\ncornify_add = function() {\n\tcornify_count += 1;\n\tvar cornify_url = 'http://www.cornify.com/';\n\tvar div = document.createElement('div');\n\tdiv.style.position = 'fixed';\n\t\n\tvar numType = 'px';\n\tvar heightRandom = Math.random()*.75;\n\tvar windowHeight = 768;\n\tvar windowWidth = 1024;\n\tvar height = 0;\n\tvar width = 0;\n\tvar de = document.documentElement;\n\tif (typeof(window.innerHeight) == 'number') {\n\t\twindowHeight = window.innerHeight;\n\t\twindowWidth = window.innerWidth;\n\t} else if(de && de.clientHeight) {\n\t\twindowHeight = de.clientHeight;\n\t\twindowWidth = de.clientWidth;\n\t} else {\n\t\tnumType = '%';\n\t\theight = Math.round( height*100 )+'%';\n\t}\n\t\n\tdiv.onclick = cornify_add;\n\tdiv.style.zIndex = 10;\n\tdiv.style.outline = 0;\n\t\n\tif( cornify_count==15 ) {\n\t\tdiv.style.top = Math.max( 0, Math.round( (windowHeight-530)/2 ) )  + 'px';\n\t\tdiv.style.left = Math.round( (windowWidth-530)/2 ) + 'px';\n\t\tdiv.style.zIndex = 1000;\n\t} else {\n\t\tif( numType=='px' ) div.style.top = Math.round( windowHeight*heightRandom ) + numType;\n\t\telse div.style.top = height;\n\t\tdiv.style.left = Math.round( Math.random()*90 ) + '%';\n\t}\n\t\n\tvar img = document.createElement('img');\n\tvar currentTime = new Date();\n\tvar submitTime = currentTime.getTime();\n\tif( cornify_count==15 ) submitTime = 0;\n\timg.setAttribute('src',cornify_url+'getacorn.php?r=' + submitTime + '&url='+document.location.href);\n\tvar ease = \"all .1s linear\";\n\t//div.style['-webkit-transition'] = ease;\n\t//div.style.webkitTransition = ease;\n\tdiv.style.WebkitTransition = ease;\n\tdiv.style.WebkitTransform = \"rotate(1deg) scale(1.01,1.01)\";\n\t//div.style.MozTransition = \"all .1s linear\";\n\tdiv.style.transition = \"all .1s linear\";\n\tdiv.onmouseover = function() {\n\t\tvar size = 1+Math.round(Math.random()*10)/100;\n\t\tvar angle = Math.round(Math.random()*20-10);\n\t\tvar result = \"rotate(\"+angle+\"deg) scale(\"+size+\",\"+size+\")\";\n\t\tthis.style.transform = result;\n\t\t//this.style['-webkit-transform'] = result;\n\t\t//this.style.webkitTransform = result;\n\t\tthis.style.WebkitTransform = result;\n\t\t//this.style.MozTransform = result;\n\t\t//alert(this + ' | ' + result);\n\t}\n\tdiv.onmouseout = function() {\n\t\tvar size = .9+Math.round(Math.random()*10)/100;\n\t\tvar angle = Math.round(Math.random()*6-3);\n\t\tvar result = \"rotate(\"+angle+\"deg) scale(\"+size+\",\"+size+\")\";\n\t\tthis.style.transform = result;\t\n\t\t//this.style['-webkit-transform'] = result;\n\t\t//this.style.webkitTransform = result;\n\t\tthis.style.WebkitTransform = result;\n\t\t//this.style.MozTransform = result;\n\t}\n\tvar body = document.getElementsByTagName('body')[0];\n\tbody.appendChild(div);\n\tdiv.appendChild(img);\t\n\t\n\t// Add stylesheet.\n\tif (cornify_count == 5) {\n\t\tvar cssExisting = document.getElementById('__cornify_css');\n\t\tif (!cssExisting) {\n\t\t\tvar head = document.getElementsByTagName(\"head\")[0];\n\t\t\tvar css = document.createElement('link');\n\t\t\tcss.id = '__cornify_css';\n\t\t\tcss.type = 'text/css';\n\t\t\tcss.rel = 'stylesheet';\n\t\t\tcss.href = 'http://www.cornify.com/css/cornify.css';\n\t\t\tcss.media = 'screen';\n\t\t\thead.appendChild(css);\n\t\t}\n\t\tcornify_replace();\n\t}\t\n}\n\ncornify_replace = function() {\n\t// Replace text.\n\tvar hc = 6;\n\tvar hs;\n\tvar h;\n\tvar k;\n\tvar words = ['Happy','Sparkly','Glittery','Fun','Magical','Lovely','Cute','Charming','Amazing','Wonderful'];\n\twhile(hc >= 1) {\n\t\ths = document.getElementsByTagName('h' + hc);\n\t\tfor (k = 0; k < hs.length; k++) {\n\t\t\th = hs[k];\n\t\t\th.innerHTML = words[Math.floor(Math.random()*words.length)] + ' ' + h.innerHTML;\n\t\t}\n\t\thc-=1;\n\t}\n}\n\n/*\n * Adapted from http://www.snaptortoise.com/konami-js/\n */\nvar cornami = {\n\tinput:\"\",\n\tpattern:\"38384040373937396665\",\n\tclear:setTimeout('cornami.clear_input()',5000),\n\tload: function() {\n\t\twindow.document.onkeydown = function(e) {\n\t\t\tif (cornami.input == cornami.pattern) {\n\t\t\t\tcornify_add();\n\t\t\t\tclearTimeout(cornami.clear);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\telse {\n\t\t\t\tcornami.input += e ? e.keyCode : event.keyCode;\n\t\t\t\tif (cornami.input == cornami.pattern) cornify_add();\n\t\t\t\tclearTimeout(cornami.clear);\n\t\t\t\tcornami.clear = setTimeout(\"cornami.clear_input()\", 5000);\n\t\t\t}\n\t\t}\n\t},\n\tclear_input: function() {\n\t\tcornami.input=\"\";\n\t\tclearTimeout(cornami.clear);\n\t}\n}\ncornami.load();"
  },
  {
    "path": "js/admin/dc.js",
    "content": "/*!\n *  dc 3.0.2\n *  http://dc-js.github.io/dc.js/\n *  Copyright 2012-2016 Nick Zhu & the dc.js Developers\n *  https://github.com/dc-js/dc.js/blob/master/AUTHORS\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 */\n(function() { function _dc(d3, crossfilter) {\n'use strict';\n\n/**\n * The entire dc.js library is scoped under the **dc** name space. It does not introduce\n * anything else into the global name space.\n *\n * Most `dc` functions are designed to allow function chaining, meaning they return the current chart\n * instance whenever it is appropriate.  The getter forms of functions do not participate in function\n * chaining because they return values that are not the chart, although some,\n * such as {@link dc.baseMixin#svg .svg} and {@link dc.coordinateGridMixin#xAxis .xAxis},\n * return values that are themselves chainable d3 objects.\n * @namespace dc\n * @version 3.0.2\n * @example\n * // Example chaining\n * chart.width(300)\n *      .height(300)\n *      .filter('sunday');\n */\n/*jshint -W079*/\nvar dc = {\n    version: '3.0.2',\n    constants: {\n        CHART_CLASS: 'dc-chart',\n        DEBUG_GROUP_CLASS: 'debug',\n        STACK_CLASS: 'stack',\n        DESELECTED_CLASS: 'deselected',\n        SELECTED_CLASS: 'selected',\n        NODE_INDEX_NAME: '__index__',\n        GROUP_INDEX_NAME: '__group_index__',\n        DEFAULT_CHART_GROUP: '__default_chart_group__',\n        EVENT_DELAY: 40,\n        NEGLIGIBLE_NUMBER: 1e-10\n    },\n    _renderlet: null\n};\n/*jshint +W079*/\n\n/**\n * The dc.chartRegistry object maintains sets of all instantiated dc.js charts under named groups\n * and the default group.\n *\n * A chart group often corresponds to a crossfilter instance. It specifies\n * the set of charts which should be updated when a filter changes on one of the charts or when the\n * global functions {@link dc.filterAll dc.filterAll}, {@link dc.refocusAll dc.refocusAll},\n * {@link dc.renderAll dc.renderAll}, {@link dc.redrawAll dc.redrawAll}, or chart functions\n * {@link dc.baseMixin#renderGroup baseMixin.renderGroup},\n * {@link dc.baseMixin#redrawGroup baseMixin.redrawGroup} are called.\n *\n * @namespace chartRegistry\n * @memberof dc\n * @type {{has, register, deregister, clear, list}}\n */\ndc.chartRegistry = (function () {\n    // chartGroup:string => charts:array\n    var _chartMap = {};\n\n    function initializeChartGroup (group) {\n        if (!group) {\n            group = dc.constants.DEFAULT_CHART_GROUP;\n        }\n\n        if (!_chartMap[group]) {\n            _chartMap[group] = [];\n        }\n\n        return group;\n    }\n\n    return {\n        /**\n         * Determine if a given chart instance resides in any group in the registry.\n         * @method has\n         * @memberof dc.chartRegistry\n         * @param {Object} chart dc.js chart instance\n         * @returns {Boolean}\n         */\n        has: function (chart) {\n            for (var e in _chartMap) {\n                if (_chartMap[e].indexOf(chart) >= 0) {\n                    return true;\n                }\n            }\n            return false;\n        },\n\n        /**\n         * Add given chart instance to the given group, creating the group if necessary.\n         * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used.\n         * @method register\n         * @memberof dc.chartRegistry\n         * @param {Object} chart dc.js chart instance\n         * @param {String} [group] Group name\n         */\n        register: function (chart, group) {\n            group = initializeChartGroup(group);\n            _chartMap[group].push(chart);\n        },\n\n        /**\n         * Remove given chart instance from the given group, creating the group if necessary.\n         * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used.\n         * @method deregister\n         * @memberof dc.chartRegistry\n         * @param {Object} chart dc.js chart instance\n         * @param {String} [group] Group name\n         */\n        deregister: function (chart, group) {\n            group = initializeChartGroup(group);\n            for (var i = 0; i < _chartMap[group].length; i++) {\n                if (_chartMap[group][i].anchorName() === chart.anchorName()) {\n                    _chartMap[group].splice(i, 1);\n                    break;\n                }\n            }\n        },\n\n        /**\n         * Clear given group if one is provided, otherwise clears all groups.\n         * @method clear\n         * @memberof dc.chartRegistry\n         * @param {String} group Group name\n         */\n        clear: function (group) {\n            if (group) {\n                delete _chartMap[group];\n            } else {\n                _chartMap = {};\n            }\n        },\n\n        /**\n         * Get an array of each chart instance in the given group.\n         * If no group is provided, the charts in the default group are returned.\n         * @method list\n         * @memberof dc.chartRegistry\n         * @param {String} [group] Group name\n         * @returns {Array<Object>}\n         */\n        list: function (group) {\n            group = initializeChartGroup(group);\n            return _chartMap[group];\n        }\n    };\n})();\n\n/**\n * Add given chart instance to the given group, creating the group if necessary.\n * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used.\n * @memberof dc\n * @method registerChart\n * @param {Object} chart dc.js chart instance\n * @param {String} [group] Group name\n */\ndc.registerChart = function (chart, group) {\n    dc.chartRegistry.register(chart, group);\n};\n\n/**\n * Remove given chart instance from the given group, creating the group if necessary.\n * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used.\n * @memberof dc\n * @method deregisterChart\n * @param {Object} chart dc.js chart instance\n * @param {String} [group] Group name\n */\ndc.deregisterChart = function (chart, group) {\n    dc.chartRegistry.deregister(chart, group);\n};\n\n/**\n * Determine if a given chart instance resides in any group in the registry.\n * @memberof dc\n * @method hasChart\n * @param {Object} chart dc.js chart instance\n * @returns {Boolean}\n */\ndc.hasChart = function (chart) {\n    return dc.chartRegistry.has(chart);\n};\n\n/**\n * Clear given group if one is provided, otherwise clears all groups.\n * @memberof dc\n * @method deregisterAllCharts\n * @param {String} group Group name\n */\ndc.deregisterAllCharts = function (group) {\n    dc.chartRegistry.clear(group);\n};\n\n/**\n * Clear all filters on all charts within the given chart group. If the chart group is not given then\n * only charts that belong to the default chart group will be reset.\n * @memberof dc\n * @method filterAll\n * @param {String} [group]\n */\ndc.filterAll = function (group) {\n    var charts = dc.chartRegistry.list(group);\n    for (var i = 0; i < charts.length; ++i) {\n        charts[i].filterAll();\n    }\n};\n\n/**\n * Reset zoom level / focus on all charts that belong to the given chart group. If the chart group is\n * not given then only charts that belong to the default chart group will be reset.\n * @memberof dc\n * @method refocusAll\n * @param {String} [group]\n */\ndc.refocusAll = function (group) {\n    var charts = dc.chartRegistry.list(group);\n    for (var i = 0; i < charts.length; ++i) {\n        if (charts[i].focus) {\n            charts[i].focus();\n        }\n    }\n};\n\n/**\n * Re-render all charts belong to the given chart group. If the chart group is not given then only\n * charts that belong to the default chart group will be re-rendered.\n * @memberof dc\n * @method renderAll\n * @param {String} [group]\n */\ndc.renderAll = function (group) {\n    var charts = dc.chartRegistry.list(group);\n    for (var i = 0; i < charts.length; ++i) {\n        charts[i].render();\n    }\n\n    if (dc._renderlet !== null) {\n        dc._renderlet(group);\n    }\n};\n\n/**\n * Redraw all charts belong to the given chart group. If the chart group is not given then only charts\n * that belong to the default chart group will be re-drawn. Redraw is different from re-render since\n * when redrawing dc tries to update the graphic incrementally, using transitions, instead of starting\n * from scratch.\n * @memberof dc\n * @method redrawAll\n * @param {String} [group]\n */\ndc.redrawAll = function (group) {\n    var charts = dc.chartRegistry.list(group);\n    for (var i = 0; i < charts.length; ++i) {\n        charts[i].redraw();\n    }\n\n    if (dc._renderlet !== null) {\n        dc._renderlet(group);\n    }\n};\n\n/**\n * If this boolean is set truthy, all transitions will be disabled, and changes to the charts will happen\n * immediately.\n * @memberof dc\n * @member disableTransitions\n * @type {Boolean}\n * @default false\n */\ndc.disableTransitions = false;\n\n/**\n * Start a transition on a selection if transitions are globally enabled\n * ({@link dc.disableTransitions} is false) and the duration is greater than zero; otherwise return\n * the selection. Since most operations are the same on a d3 selection and a d3 transition, this\n * allows a common code path for both cases.\n * @memberof dc\n * @method transition\n * @param {d3.selection} selection - the selection to be transitioned\n * @param {Number|Function} [duration=250] - the duration of the transition in milliseconds, a\n * function returning the duration, or 0 for no transition\n * @param {Number|Function} [delay] - the delay of the transition in milliseconds, or a function\n * returning the delay, or 0 for no delay\n * @param {String} [name] - the name of the transition (if concurrent transitions on the same\n * elements are needed)\n * @returns {d3.transition|d3.selection}\n */\ndc.transition = function (selection, duration, delay, name) {\n    if (dc.disableTransitions || duration <= 0) {\n        return selection;\n    }\n\n    var s = selection.transition(name);\n\n    if (duration >= 0 || duration !== undefined) {\n        s = s.duration(duration);\n    }\n    if (delay >= 0 || delay !== undefined) {\n        s = s.delay(delay);\n    }\n\n    return s;\n};\n\n/* somewhat silly, but to avoid duplicating logic */\ndc.optionalTransition = function (enable, duration, delay, name) {\n    if (enable) {\n        return function (selection) {\n            return dc.transition(selection, duration, delay, name);\n        };\n    } else {\n        return function (selection) {\n            return selection;\n        };\n    }\n};\n\n// See http://stackoverflow.com/a/20773846\ndc.afterTransition = function (transition, callback) {\n    if (transition.empty() || !transition.duration) {\n        callback.call(transition);\n    } else {\n        var n = 0;\n        transition\n            .each(function () { ++n; })\n            .on('end', function () {\n                if (!--n) {\n                    callback.call(transition);\n                }\n            });\n    }\n};\n\n/**\n * @namespace units\n * @memberof dc\n * @type {{}}\n */\ndc.units = {};\n\n/**\n * The default value for {@link dc.coordinateGridMixin#xUnits .xUnits} for the\n * {@link dc.coordinateGridMixin Coordinate Grid Chart} and should\n * be used when the x values are a sequence of integers.\n * It is a function that counts the number of integers in the range supplied in its start and end parameters.\n * @method integers\n * @memberof dc.units\n * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits}\n * @example\n * chart.xUnits(dc.units.integers) // already the default\n * @param {Number} start\n * @param {Number} end\n * @returns {Number}\n */\ndc.units.integers = function (start, end) {\n    return Math.abs(end - start);\n};\n\n/**\n * This argument can be passed to the {@link dc.coordinateGridMixin#xUnits .xUnits} function of a\n * coordinate grid chart to specify ordinal units for the x axis. Usually this parameter is used in\n * combination with passing\n * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal}\n * to {@link dc.coordinateGridMixin#x .x}.\n *\n * As of dc.js 3.0, this is purely a placeholder or magic value which causes the chart to go into ordinal mode; the\n * function is not called.\n * @method ordinal\n * @memberof dc.units\n * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal}\n * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits}\n * @see {@link dc.coordinateGridMixin#x coordinateGridMixin.x}\n * @example\n * chart.xUnits(dc.units.ordinal)\n *      .x(d3.scaleOrdinal())\n */\ndc.units.ordinal = function () {\n    throw new Error('dc.units.ordinal should not be called - it is a placeholder');\n};\n\n/**\n * @namespace fp\n * @memberof dc.units\n * @type {{}}\n */\ndc.units.fp = {};\n/**\n * This function generates an argument for the {@link dc.coordinateGridMixin Coordinate Grid Chart}\n * {@link dc.coordinateGridMixin#xUnits .xUnits} function specifying that the x values are floating-point\n * numbers with the given precision.\n * The returned function determines how many values at the given precision will fit into the range\n * supplied in its start and end parameters.\n * @method precision\n * @memberof dc.units.fp\n * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits}\n * @example\n * // specify values (and ticks) every 0.1 units\n * chart.xUnits(dc.units.fp.precision(0.1)\n * // there are 500 units between 0.5 and 1 if the precision is 0.001\n * var thousandths = dc.units.fp.precision(0.001);\n * thousandths(0.5, 1.0) // returns 500\n * @param {Number} precision\n * @returns {Function} start-end unit function\n */\ndc.units.fp.precision = function (precision) {\n    var _f = function (s, e) {\n        var d = Math.abs((e - s) / _f.resolution);\n        if (dc.utils.isNegligible(d - Math.floor(d))) {\n            return Math.floor(d);\n        } else {\n            return Math.ceil(d);\n        }\n    };\n    _f.resolution = precision;\n    return _f;\n};\n\ndc.round = {};\ndc.round.floor = function (n) {\n    return Math.floor(n);\n};\ndc.round.ceil = function (n) {\n    return Math.ceil(n);\n};\ndc.round.round = function (n) {\n    return Math.round(n);\n};\n\ndc.override = function (obj, functionName, newFunction) {\n    var existingFunction = obj[functionName];\n    obj['_' + functionName] = existingFunction;\n    obj[functionName] = newFunction;\n};\n\ndc.renderlet = function (_) {\n    if (!arguments.length) {\n        return dc._renderlet;\n    }\n    dc._renderlet = _;\n    return dc;\n};\n\ndc.instanceOfChart = function (o) {\n    return o instanceof Object && o.__dcFlag__ && true;\n};\n\ndc.errors = {};\n\ndc.errors.Exception = function (msg) {\n    var _msg = msg || 'Unexpected internal error';\n\n    this.message = _msg;\n\n    this.toString = function () {\n        return _msg;\n    };\n    this.stack = (new Error()).stack;\n};\ndc.errors.Exception.prototype = Object.create(Error.prototype);\ndc.errors.Exception.prototype.constructor = dc.errors.Exception;\n\ndc.errors.InvalidStateException = function () {\n    dc.errors.Exception.apply(this, arguments);\n};\n\ndc.errors.InvalidStateException.prototype = Object.create(dc.errors.Exception.prototype);\ndc.errors.InvalidStateException.prototype.constructor = dc.errors.InvalidStateException;\n\ndc.errors.BadArgumentException = function () {\n    dc.errors.Exception.apply(this, arguments);\n};\n\ndc.errors.BadArgumentException.prototype = Object.create(dc.errors.Exception.prototype);\ndc.errors.BadArgumentException.prototype.constructor = dc.errors.BadArgumentException;\n\n/**\n * The default date format for dc.js\n * @name dateFormat\n * @memberof dc\n * @type {Function}\n * @default d3.timeFormat('%m/%d/%Y')\n */\ndc.dateFormat = d3.timeFormat('%m/%d/%Y');\n\n/**\n * @namespace printers\n * @memberof dc\n * @type {{}}\n */\ndc.printers = {};\n\n/**\n * Converts a list of filters into a readable string.\n * @method filters\n * @memberof dc.printers\n * @param {Array<dc.filters>} filters\n * @returns {String}\n */\ndc.printers.filters = function (filters) {\n    var s = '';\n\n    for (var i = 0; i < filters.length; ++i) {\n        if (i > 0) {\n            s += ', ';\n        }\n        s += dc.printers.filter(filters[i]);\n    }\n\n    return s;\n};\n\n/**\n * Converts a filter into a readable string.\n * @method filter\n * @memberof dc.printers\n * @param {dc.filters|any|Array<any>} filter\n * @returns {String}\n */\ndc.printers.filter = function (filter) {\n    var s = '';\n\n    if (typeof filter !== 'undefined' && filter !== null) {\n        if (filter instanceof Array) {\n            if (filter.length >= 2) {\n                s = '[' + dc.utils.printSingleValue(filter[0]) + ' -> ' + dc.utils.printSingleValue(filter[1]) + ']';\n            } else if (filter.length >= 1) {\n                s = dc.utils.printSingleValue(filter[0]);\n            }\n        } else {\n            s = dc.utils.printSingleValue(filter);\n        }\n    }\n\n    return s;\n};\n\n/**\n * Returns a function that given a string property name, can be used to pluck the property off an object.  A function\n * can be passed as the second argument to also alter the data being returned.\n *\n * This can be a useful shorthand method to create accessor functions.\n * @method pluck\n * @memberof dc\n * @example\n * var xPluck = dc.pluck('x');\n * var objA = {x: 1};\n * xPluck(objA) // 1\n * @example\n * var xPosition = dc.pluck('x', function (x, i) {\n *     // `this` is the original datum,\n *     // `x` is the x property of the datum,\n *     // `i` is the position in the array\n *     return this.radius + x;\n * });\n * dc.selectAll('.circle').data(...).x(xPosition);\n * @param {String} n\n * @param {Function} [f]\n * @returns {Function}\n */\ndc.pluck = function (n, f) {\n    if (!f) {\n        return function (d) { return d[n]; };\n    }\n    return function (d, i) { return f.call(d, d[n], i); };\n};\n\n/**\n * @namespace utils\n * @memberof dc\n * @type {{}}\n */\ndc.utils = {};\n\n/**\n * Print a single value filter.\n * @method printSingleValue\n * @memberof dc.utils\n * @param {any} filter\n * @returns {String}\n */\ndc.utils.printSingleValue = function (filter) {\n    var s = '' + filter;\n\n    if (filter instanceof Date) {\n        s = dc.dateFormat(filter);\n    } else if (typeof(filter) === 'string') {\n        s = filter;\n    } else if (dc.utils.isFloat(filter)) {\n        s = dc.utils.printSingleValue.fformat(filter);\n    } else if (dc.utils.isInteger(filter)) {\n        s = Math.round(filter);\n    }\n\n    return s;\n};\ndc.utils.printSingleValue.fformat = d3.format('.2f');\n\n// convert 'day' to 'timeDay' and similar\ndc.utils.toTimeFunc = function (t) {\n    return 'time' + t.charAt(0).toUpperCase() + t.slice(1);\n};\n\n/**\n * Arbitrary add one value to another.\n *\n * If the value l is of type Date, adds r units to it. t becomes the unit.\n * For example dc.utils.add(dt, 3, 'week') will add 3 (r = 3) weeks (t= 'week') to dt.\n *\n * If l is of type numeric, t is ignored. In this case if r is of type string,\n * it is assumed to be percentage (whether or not it includes %). For example\n * dc.utils.add(30, 10) will give 40 and dc.utils.add(30, '10') will give 33.\n *\n * They also generate strange results if l is a string.\n * @method add\n * @memberof dc.utils\n * @param {Date|Number} l the value to modify\n * @param {String|Number} r the amount by which to modify the value\n * @param {Function|String} [t=d3.timeDay] if `l` is a `Date`, then this should be a\n * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval).\n * For backward compatibility with dc.js 2.0, it can also be the name of an interval, i.e.\n * 'millis', 'second', 'minute', 'hour', 'day', 'week', 'month', or 'year'\n * @returns {Date|Number}\n */\ndc.utils.add = function (l, r, t) {\n    if (typeof r === 'string') {\n        r = r.replace('%', '');\n    }\n\n    if (l instanceof Date) {\n        if (typeof r === 'string') {\n            r = +r;\n        }\n        if (t === 'millis') {\n            return new Date(l.getTime() + r);\n        }\n        t = t || d3.timeDay;\n        if (typeof t !== 'function') {\n            t = d3[dc.utils.toTimeFunc(t)];\n        }\n        return t.offset(l, r);\n    } else if (typeof r === 'string') {\n        var percentage = (+r / 100);\n        return l > 0 ? l * (1 + percentage) : l * (1 - percentage);\n    } else {\n        return l + r;\n    }\n};\n\n/**\n * Arbitrary subtract one value from another.\n *\n * If the value l is of type Date, subtracts r units from it. t becomes the unit.\n * For example dc.utils.subtract(dt, 3, 'week') will subtract 3 (r = 3) weeks (t= 'week') from dt.\n *\n * If l is of type numeric, t is ignored. In this case if r is of type string,\n * it is assumed to be percentage (whether or not it includes %). For example\n * dc.utils.subtract(30, 10) will give 20 and dc.utils.subtract(30, '10') will give 27.\n *\n * They also generate strange results if l is a string.\n * @method subtract\n * @memberof dc.utils\n * @param {Date|Number} l the value to modify\n * @param {String|Number} r the amount by which to modify the value\n * @param {Function|String} [t=d3.timeDay] if `l` is a `Date`, then this should be a\n * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval).\n * For backward compatibility with dc.js 2.0, it can also be the name of an interval, i.e.\n * 'millis', 'second', 'minute', 'hour', 'day', 'week', 'month', or 'year'\n * @returns {Date|Number}\n */\ndc.utils.subtract = function (l, r, t) {\n    if (typeof r === 'string') {\n        r = r.replace('%', '');\n    }\n\n    if (l instanceof Date) {\n        if (typeof r === 'string') {\n            r = +r;\n        }\n        if (t === 'millis') {\n            return new Date(l.getTime() - r);\n        }\n        t = t || d3.timeDay;\n        if (typeof t !== 'function') {\n            t = d3[dc.utils.toTimeFunc(t)];\n        }\n        return t.offset(l, -r);\n    } else if (typeof r === 'string') {\n        var percentage = (+r / 100);\n        return l < 0 ? l * (1 + percentage) : l * (1 - percentage);\n    } else {\n        return l - r;\n    }\n};\n\n/**\n * Is the value a number?\n * @method isNumber\n * @memberof dc.utils\n * @param {any} n\n * @returns {Boolean}\n */\ndc.utils.isNumber = function (n) {\n    return n === +n;\n};\n\n/**\n * Is the value a float?\n * @method isFloat\n * @memberof dc.utils\n * @param {any} n\n * @returns {Boolean}\n */\ndc.utils.isFloat = function (n) {\n    return n === +n && n !== (n | 0);\n};\n\n/**\n * Is the value an integer?\n * @method isInteger\n * @memberof dc.utils\n * @param {any} n\n * @returns {Boolean}\n */\ndc.utils.isInteger = function (n) {\n    return n === +n && n === (n | 0);\n};\n\n/**\n * Is the value very close to zero?\n * @method isNegligible\n * @memberof dc.utils\n * @param {any} n\n * @returns {Boolean}\n */\ndc.utils.isNegligible = function (n) {\n    return !dc.utils.isNumber(n) || (n < dc.constants.NEGLIGIBLE_NUMBER && n > -dc.constants.NEGLIGIBLE_NUMBER);\n};\n\n/**\n * Ensure the value is no greater or less than the min/max values.  If it is return the boundary value.\n * @method clamp\n * @memberof dc.utils\n * @param {any} val\n * @param {any} min\n * @param {any} max\n * @returns {any}\n */\ndc.utils.clamp = function (val, min, max) {\n    return val < min ? min : (val > max ? max : val);\n};\n\n/**\n * Given `x`, return a function that always returns `x`.\n *\n * {@link https://github.com/d3/d3/blob/master/CHANGES.md#internals `d3.functor` was removed in d3 version 4}.\n * This function helps to implement the replacement,\n * `typeof x === \"function\" ? x : dc.utils.constant(x)`\n * @method constant\n * @memberof dc.utils\n * @param {any} x\n * @returns {Function}\n */\ndc.utils.constant = function (x) {\n    return function () {\n        return x;\n    };\n};\n\n/**\n * Using a simple static counter, provide a unique integer id.\n * @method uniqueId\n * @memberof dc.utils\n * @returns {Number}\n */\nvar _idCounter = 0;\ndc.utils.uniqueId = function () {\n    return ++_idCounter;\n};\n\n/**\n * Convert a name to an ID.\n * @method nameToId\n * @memberof dc.utils\n * @param {String} name\n * @returns {String}\n */\ndc.utils.nameToId = function (name) {\n    return name.toLowerCase().replace(/[\\s]/g, '_').replace(/[\\.']/g, '');\n};\n\n/**\n * Append or select an item on a parent element.\n * @method appendOrSelect\n * @memberof dc.utils\n * @param {d3.selection} parent\n * @param {String} selector\n * @param {String} tag\n * @returns {d3.selection}\n */\ndc.utils.appendOrSelect = function (parent, selector, tag) {\n    tag = tag || selector;\n    var element = parent.select(selector);\n    if (element.empty()) {\n        element = parent.append(tag);\n    }\n    return element;\n};\n\n/**\n * Return the number if the value is a number; else 0.\n * @method safeNumber\n * @memberof dc.utils\n * @param {Number|any} n\n * @returns {Number}\n */\ndc.utils.safeNumber = function (n) { return dc.utils.isNumber(+n) ? +n : 0;};\n\n/**\n * Return true if both arrays are equal, if both array are null these are considered equal\n * @method arraysEqual\n * @memberof dc.utils\n * @param {Array|null} a1\n * @param {Array|null} a2\n * @returns {Boolean}\n */\ndc.utils.arraysEqual = function (a1, a2) {\n    if (!a1 || !a2) {\n        return a1 === a2;\n    }\n\n    return a1.length === a2.length &&\n        // If elements are not integers/strings, we hope that it will match because of toString\n        // Test cases cover dates as well.\n        a1.every(function (elem, i) {\n            return elem === a2[i] || elem.toString() === a2[i].toString();\n        });\n};\n\n// ******** Sunburst Chart ********\ndc.utils.allChildren = function (node) {\n    var paths = [];\n    paths.push(node.path);\n    console.log('currentNode', node);\n    if (node.children) {\n        for (var i = 0; i < node.children.length; i++) {\n            paths = paths.concat(dc.utils.allChildren(node.children[i]));\n        }\n    }\n    return paths;\n};\n\n// builds a d3 Hierarchy from a collection\n// TODO: turn this monster method something better.\ndc.utils.toHierarchy = function (list, accessor) {\n    var root = {'key': 'root', 'children': []};\n    for (var i = 0; i < list.length; i++) {\n        var data = list[i];\n        var parts = data.key;\n        var value = accessor(data);\n        var currentNode = root;\n        for (var j = 0; j < parts.length; j++) {\n            var currentPath = parts.slice(0, j + 1);\n            var children = currentNode.children;\n            var nodeName = parts[j];\n            var childNode;\n            if (j + 1 < parts.length) {\n                // Not yet at the end of the sequence; move down the tree.\n                childNode = findChild(children, nodeName);\n\n                // If we don't already have a child node for this branch, create it.\n                if (childNode === void 0) {\n                    childNode = {'key': nodeName, 'children': [], 'path': currentPath};\n                    children.push(childNode);\n                }\n                currentNode = childNode;\n            } else {\n                // Reached the end of the sequence; create a leaf node.\n                childNode = {'key': nodeName, 'value': value, 'data': data, 'path': currentPath};\n                children.push(childNode);\n            }\n        }\n    }\n    return root;\n};\n\nfunction findChild (children, nodeName) {\n    for (var k = 0; k < children.length; k++) {\n        if (children[k].key === nodeName) {\n            return children[k];\n        }\n    }\n}\n\ndc.utils.getAncestors = function (node) {\n    var path = [];\n    var current = node;\n    while (current.parent) {\n        path.unshift(current.name);\n        current = current.parent;\n    }\n    return path;\n};\n\ndc.utils.arraysIdentical = function (a, b) {\n    var i = a.length;\n    if (i !== b.length) {\n        return false;\n    }\n    while (i--) {\n        if (a[i] !== b[i]) {\n            return false;\n        }\n    }\n    return true;\n};\n\n/**\n * Provides basis logging and deprecation utilities\n * @class logger\n * @memberof dc\n * @returns {dc.logger}\n */\ndc.logger = (function () {\n\n    var _logger = {};\n\n    /**\n     * Enable debug level logging. Set to `false` by default.\n     * @name enableDebugLog\n     * @memberof dc.logger\n     * @instance\n     */\n    _logger.enableDebugLog = false;\n\n    /**\n     * Put a warning message to console\n     * @method warn\n     * @memberof dc.logger\n     * @instance\n     * @example\n     * dc.logger.warn('Invalid use of .tension on CurveLinear');\n     * @param {String} [msg]\n     * @returns {dc.logger}\n     */\n    _logger.warn = function (msg) {\n        if (console) {\n            if (console.warn) {\n                console.warn(msg);\n            } else if (console.log) {\n                console.log(msg);\n            }\n        }\n\n        return _logger;\n    };\n\n    var _alreadyWarned = {};\n\n    /**\n     * Put a warning message to console. It will warn only on unique messages.\n     * @method warnOnce\n     * @memberof dc.logger\n     * @instance\n     * @example\n     * dc.logger.warnOnce('Invalid use of .tension on CurveLinear');\n     * @param {String} [msg]\n     * @returns {dc.logger}\n     */\n    _logger.warnOnce = function (msg) {\n        if (!_alreadyWarned[msg]) {\n            _alreadyWarned[msg] = true;\n\n            dc.logger.warn(msg);\n        }\n\n        return _logger;\n    };\n\n    /**\n     * Put a debug message to console. It is controlled by `dc.logger.enableDebugLog`\n     * @method debug\n     * @memberof dc.logger\n     * @instance\n     * @example\n     * dc.logger.debug('Total number of slices: ' + numSlices);\n     * @param {String} [msg]\n     * @returns {dc.logger}\n     */\n    _logger.debug = function (msg) {\n        if (_logger.enableDebugLog && console) {\n            if (console.debug) {\n                console.debug(msg);\n            } else if (console.log) {\n                console.log(msg);\n            }\n        }\n\n        return _logger;\n    };\n\n    /**\n     * Use it to deprecate a function. It will return a wrapped version of the function, which will\n     * will issue a warning when invoked. For each function, warning will be issued only once.\n     *\n     * @method deprecate\n     * @memberof dc.logger\n     * @instance\n     * @example\n     * _chart.interpolate = dc.logger.deprecate(function (interpolate) {\n     *    if (!arguments.length) {\n     *        return _interpolate;\n     *    }\n     *    _interpolate = interpolate;\n     *    return _chart;\n     * }, 'dc.lineChart.interpolate has been deprecated since version 3.0 use dc.lineChart.curve instead');\n     * @param {Function} [fn]\n     * @param {String} [msg]\n     * @returns {Function}\n     */\n    _logger.deprecate = function (fn, msg) {\n        // Allow logging of deprecation\n        var warned = false;\n        function deprecated () {\n            if (!warned) {\n                _logger.warn(msg);\n                warned = true;\n            }\n            return fn.apply(this, arguments);\n        }\n        return deprecated;\n    };\n\n    return _logger;\n})();\n\n/**\n * General configuration\n *\n * @class config\n * @memberof dc\n * @returns {dc.config}\n */\ndc.config = (function () {\n    var _config = {};\n\n    // D3v5 has removed schemeCategory20c, copied here for backward compatibility\n    var _schemeCategory20c = [\n        '#3182bd', '#6baed6', '#9ecae1', '#c6dbef', '#e6550d',\n        '#fd8d3c', '#fdae6b', '#fdd0a2', '#31a354', '#74c476',\n        '#a1d99b', '#c7e9c0', '#756bb1', '#9e9ac8', '#bcbddc',\n        '#dadaeb', '#636363', '#969696', '#bdbdbd', '#d9d9d9'];\n\n    var _defaultColors = _schemeCategory20c;\n\n    /**\n     * Set the default color scheme for ordinal charts. Changing it will impact all ordinal charts.\n     *\n     * By default it is set to a copy of\n     * `d3.schemeCategory20c` for backward compatibility. This color scheme has been\n     * [removed from D3v5](https://github.com/d3/d3/blob/master/CHANGES.md#changes-in-d3-50).\n     * In DC 3.1 release it will change to a more appropriate default.\n     *\n     * @example\n     * dc.config.defaultColors(d3.schemeSet1)\n     * @method defaultColors\n     * @memberof dc.config\n     * @instance\n     * @param {Array} [colors]\n     * @returns {Array|dc.config}\n     */\n    _config.defaultColors = function (colors) {\n        if (!arguments.length) {\n            // Issue warning if it uses _schemeCategory20c\n            if (_defaultColors === _schemeCategory20c) {\n                dc.logger.warnOnce('You are using d3.schemeCategory20c, which has been removed in D3v5. ' +\n                    'See the explanation at https://github.com/d3/d3/blob/master/CHANGES.md#changes-in-d3-50. ' +\n                    'DC is using it for backward compatibility, however it will be changed in DCv3.1. ' +\n                    'You can change it by calling dc.config.defaultColors(newScheme). ' +\n                    'See https://github.com/d3/d3-scale-chromatic for some alternatives.');\n            }\n            return _defaultColors;\n        }\n        _defaultColors = colors;\n        return _config;\n    };\n\n    return _config;\n})();\n\ndc.events = {\n    current: null\n};\n\n/**\n * This function triggers a throttled event function with a specified delay (in milli-seconds).  Events\n * that are triggered repetitively due to user interaction such brush dragging might flood the library\n * and invoke more renders than can be executed in time. Using this function to wrap your event\n * function allows the library to smooth out the rendering by throttling events and only responding to\n * the most recent event.\n * @name events.trigger\n * @memberof dc\n * @example\n * chart.on('renderlet', function(chart) {\n *     // smooth the rendering through event throttling\n *     dc.events.trigger(function(){\n *         // focus some other chart to the range selected by user on this chart\n *         someOtherChart.focus(chart.filter());\n *     });\n * })\n * @param {Function} closure\n * @param {Number} [delay]\n */\ndc.events.trigger = function (closure, delay) {\n    if (!delay) {\n        closure();\n        return;\n    }\n\n    dc.events.current = closure;\n\n    setTimeout(function () {\n        if (closure === dc.events.current) {\n            closure();\n        }\n    }, delay);\n};\n\n/**\n * The dc.js filters are functions which are passed into crossfilter to chose which records will be\n * accumulated to produce values for the charts.  In the crossfilter model, any filters applied on one\n * dimension will affect all the other dimensions but not that one.  dc always applies a filter\n * function to the dimension; the function combines multiple filters and if any of them accept a\n * record, it is filtered in.\n *\n * These filter constructors are used as appropriate by the various charts to implement brushing.  We\n * mention below which chart uses which filter.  In some cases, many instances of a filter will be added.\n *\n * Each of the dc.js filters is an object with the following properties:\n * * `isFiltered` - a function that returns true if a value is within the filter\n * * `filterType` - a string identifying the filter, here the name of the constructor\n *\n * Currently these filter objects are also arrays, but this is not a requirement. Custom filters\n * can be used as long as they have the properties above.\n * @namespace filters\n * @memberof dc\n * @type {{}}\n */\ndc.filters = {};\n\n/**\n * RangedFilter is a filter which accepts keys between `low` and `high`.  It is used to implement X\n * axis brushing for the {@link dc.coordinateGridMixin coordinate grid charts}.\n *\n * Its `filterType` is 'RangedFilter'\n * @name RangedFilter\n * @memberof dc.filters\n * @param {Number} low\n * @param {Number} high\n * @returns {Array<Number>}\n * @constructor\n */\ndc.filters.RangedFilter = function (low, high) {\n    var range = new Array(low, high);\n    range.isFiltered = function (value) {\n        return value >= this[0] && value < this[1];\n    };\n    range.filterType = 'RangedFilter';\n\n    return range;\n};\n\n/**\n * TwoDimensionalFilter is a filter which accepts a single two-dimensional value.  It is used by the\n * {@link dc.heatMap heat map chart} to include particular cells as they are clicked.  (Rows and columns are\n * filtered by filtering all the cells in the row or column.)\n *\n * Its `filterType` is 'TwoDimensionalFilter'\n * @name TwoDimensionalFilter\n * @memberof dc.filters\n * @param {Array<Number>} filter\n * @returns {Array<Number>}\n * @constructor\n */\ndc.filters.TwoDimensionalFilter = function (filter) {\n    if (filter === null) { return null; }\n\n    var f = filter;\n    f.isFiltered = function (value) {\n        return value.length && value.length === f.length &&\n               value[0] === f[0] && value[1] === f[1];\n    };\n    f.filterType = 'TwoDimensionalFilter';\n\n    return f;\n};\n\n/**\n * The RangedTwoDimensionalFilter allows filtering all values which fit within a rectangular\n * region. It is used by the {@link dc.scatterPlot scatter plot} to implement rectangular brushing.\n *\n * It takes two two-dimensional points in the form `[[x1,y1],[x2,y2]]`, and normalizes them so that\n * `x1 <= x2` and `y1 <= y2`. It then returns a filter which accepts any points which are in the\n * rectangular range including the lower values but excluding the higher values.\n *\n * If an array of two values are given to the RangedTwoDimensionalFilter, it interprets the values as\n * two x coordinates `x1` and `x2` and returns a filter which accepts any points for which `x1 <= x <\n * x2`.\n *\n * Its `filterType` is 'RangedTwoDimensionalFilter'\n * @name RangedTwoDimensionalFilter\n * @memberof dc.filters\n * @param {Array<Array<Number>>} filter\n * @returns {Array<Array<Number>>}\n * @constructor\n */\ndc.filters.RangedTwoDimensionalFilter = function (filter) {\n    if (filter === null) { return null; }\n\n    var f = filter;\n    var fromBottomLeft;\n\n    if (f[0] instanceof Array) {\n        fromBottomLeft = [\n            [Math.min(filter[0][0], filter[1][0]), Math.min(filter[0][1], filter[1][1])],\n            [Math.max(filter[0][0], filter[1][0]), Math.max(filter[0][1], filter[1][1])]\n        ];\n    } else {\n        fromBottomLeft = [[filter[0], -Infinity], [filter[1], Infinity]];\n    }\n\n    f.isFiltered = function (value) {\n        var x, y;\n\n        if (value instanceof Array) {\n            x = value[0];\n            y = value[1];\n        } else {\n            x = value;\n            y = fromBottomLeft[0][1];\n        }\n\n        return x >= fromBottomLeft[0][0] && x < fromBottomLeft[1][0] &&\n               y >= fromBottomLeft[0][1] && y < fromBottomLeft[1][1];\n    };\n    f.filterType = 'RangedTwoDimensionalFilter';\n\n    return f;\n};\n\n// ******** Sunburst Chart ********\n\n/**\n * HierarchyFilter is a filter which accepts a key path as an array. It matches any node at, or\n * child of, the given path. It is used by the {@link dc.sunburstChart sunburst chart} to include particular cells and all\n * their children as they are clicked.\n *\n * @name HierarchyFilter\n * @memberof dc.filters\n * @param {String} path\n * @returns {Array<String>}\n * @constructor\n */\ndc.filters.HierarchyFilter = function (path) {\n    if (path === null) {\n        return null;\n    }\n\n    var filter = path.slice(0);\n    filter.isFiltered = function (value) {\n        if (!(filter.length && value && value.length && value.length >= filter.length)) {\n            return false;\n        }\n\n        for (var i = 0; i < filter.length; i++) {\n            if (value[i] !== filter[i]) {\n                return false;\n            }\n        }\n\n        return true;\n    };\n    return filter;\n};\n\n/**\n * `dc.baseMixin` is an abstract functional object representing a basic `dc` chart object\n * for all chart and widget implementations. Methods from the {@link #dc.baseMixin dc.baseMixin} are inherited\n * and available on all chart implementations in the `dc` library.\n * @name baseMixin\n * @memberof dc\n * @mixin\n * @param {Object} _chart\n * @returns {dc.baseMixin}\n */\ndc.baseMixin = function (_chart) {\n    _chart.__dcFlag__ = dc.utils.uniqueId();\n\n    var _dimension;\n    var _group;\n\n    var _anchor;\n    var _root;\n    var _svg;\n    var _isChild;\n\n    var _minWidth = 200;\n    var _defaultWidthCalc = function (element) {\n        var width = element && element.getBoundingClientRect && element.getBoundingClientRect().width;\n        return (width && width > _minWidth) ? width : _minWidth;\n    };\n    var _widthCalc = _defaultWidthCalc;\n\n    var _minHeight = 200;\n    var _defaultHeightCalc = function (element) {\n        var height = element && element.getBoundingClientRect && element.getBoundingClientRect().height;\n        return (height && height > _minHeight) ? height : _minHeight;\n    };\n    var _heightCalc = _defaultHeightCalc;\n    var _width, _height;\n    var _useViewBoxResizing = false;\n\n    var _keyAccessor = dc.pluck('key');\n    var _valueAccessor = dc.pluck('value');\n    var _label = dc.pluck('key');\n\n    var _ordering = dc.pluck('key');\n    var _orderSort;\n\n    var _renderLabel = false;\n\n    var _title = function (d) {\n        return _chart.keyAccessor()(d) + ': ' + _chart.valueAccessor()(d);\n    };\n    var _renderTitle = true;\n    var _controlsUseVisibility = false;\n\n    var _transitionDuration = 750;\n\n    var _transitionDelay = 0;\n\n    var _filterPrinter = dc.printers.filters;\n\n    var _mandatoryAttributes = ['dimension', 'group'];\n\n    var _chartGroup = dc.constants.DEFAULT_CHART_GROUP;\n\n    var _listeners = d3.dispatch(\n        'preRender',\n        'postRender',\n        'preRedraw',\n        'postRedraw',\n        'filtered',\n        'zoomed',\n        'renderlet',\n        'pretransition');\n\n    var _legend;\n    var _commitHandler;\n\n    var _filters = [];\n    var _filterHandler = function (dimension, filters) {\n        if (filters.length === 0) {\n            dimension.filter(null);\n        } else if (filters.length === 1 && !filters[0].isFiltered) {\n            // single value and not a function-based filter\n            dimension.filterExact(filters[0]);\n        } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') {\n            // single range-based filter\n            dimension.filterRange(filters[0]);\n        } else {\n            dimension.filterFunction(function (d) {\n                for (var i = 0; i < filters.length; i++) {\n                    var filter = filters[i];\n                    if (filter.isFiltered && filter.isFiltered(d)) {\n                        return true;\n                    } else if (filter <= d && filter >= d) {\n                        return true;\n                    }\n                }\n                return false;\n            });\n        }\n        return filters;\n    };\n\n    var _data = function (group) {\n        return group.all();\n    };\n\n    /**\n     * Set or get the height attribute of a chart. The height is applied to the SVGElement generated by\n     * the chart when rendered (or re-rendered). If a value is given, then it will be used to calculate\n     * the new height and the chart returned for method chaining.  The value can either be a numeric, a\n     * function, or falsy. If no value is specified then the value of the current height attribute will\n     * be returned.\n     *\n     * By default, without an explicit height being given, the chart will select the width of its\n     * anchor element. If that isn't possible it defaults to 200 (provided by the\n     * {@link dc.baseMixin#minHeight minHeight} property). Setting the value falsy will return\n     * the chart to the default behavior.\n     * @method height\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#minHeight minHeight}\n     * @example\n     * // Default height\n     * chart.height(function (element) {\n     *     var height = element && element.getBoundingClientRect && element.getBoundingClientRect().height;\n     *     return (height && height > chart.minHeight()) ? height : chart.minHeight();\n     * });\n     *\n     * chart.height(250); // Set the chart's height to 250px;\n     * chart.height(function(anchor) { return doSomethingWith(anchor); }); // set the chart's height with a function\n     * chart.height(null); // reset the height to the default auto calculation\n     * @param {Number|Function} [height]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.height = function (height) {\n        if (!arguments.length) {\n            if (!dc.utils.isNumber(_height)) {\n                // only calculate once\n                _height = _heightCalc(_root.node());\n            }\n            return _height;\n        }\n        _heightCalc = height ? (typeof height === 'function' ? height : dc.utils.constant(height)) : _defaultHeightCalc;\n        _height = undefined;\n        return _chart;\n    };\n\n    /**\n     * Set or get the width attribute of a chart.\n     * @method width\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#height height}\n     * @see {@link dc.baseMixin#minWidth minWidth}\n     * @example\n     * // Default width\n     * chart.width(function (element) {\n     *     var width = element && element.getBoundingClientRect && element.getBoundingClientRect().width;\n     *     return (width && width > chart.minWidth()) ? width : chart.minWidth();\n     * });\n     * @param {Number|Function} [width]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.width = function (width) {\n        if (!arguments.length) {\n            if (!dc.utils.isNumber(_width)) {\n                // only calculate once\n                _width = _widthCalc(_root.node());\n            }\n            return _width;\n        }\n        _widthCalc = width ? (typeof width === 'function' ? width : dc.utils.constant(width)) : _defaultWidthCalc;\n        _width = undefined;\n        return _chart;\n    };\n\n    /**\n     * Set or get the minimum width attribute of a chart. This only has effect when used with the default\n     * {@link dc.baseMixin#width width} function.\n     * @method minWidth\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#width width}\n     * @param {Number} [minWidth=200]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.minWidth = function (minWidth) {\n        if (!arguments.length) {\n            return _minWidth;\n        }\n        _minWidth = minWidth;\n        return _chart;\n    };\n\n    /**\n     * Set or get the minimum height attribute of a chart. This only has effect when used with the default\n     * {@link dc.baseMixin#height height} function.\n     * @method minHeight\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#height height}\n     * @param {Number} [minHeight=200]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.minHeight = function (minHeight) {\n        if (!arguments.length) {\n            return _minHeight;\n        }\n        _minHeight = minHeight;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off using the SVG\n     * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox `viewBox` attribute}.\n     * When enabled, `viewBox` will be set on the svg root element instead of `width` and `height`.\n     * Requires that the chart aspect ratio be defined using chart.width(w) and chart.height(h).\n     *\n     * This will maintain the aspect ratio while enabling the chart to resize responsively to the\n     * space given to the chart using CSS. For example, the chart can use `width: 100%; height:\n     * 100%` or absolute positioning to resize to its parent div.\n     *\n     * Since the text will be sized as if the chart is drawn according to the width and height, and\n     * will be resized if the chart is any other size, you need to set the chart width and height so\n     * that the text looks good. In practice, 600x400 seems to work pretty well for most charts.\n     *\n     * You can see examples of this resizing strategy in the [Chart Resizing\n     * Examples](http://dc-js.github.io/dc.js/resizing/); just add `?resize=viewbox` to any of the\n     * one-chart examples to enable `useViewBoxResizing`.\n     * @method useViewBoxResizing\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Boolean} [useViewBoxResizing=false]\n     * @returns {Boolean|dc.baseMixin}\n     */\n    _chart.useViewBoxResizing = function (useViewBoxResizing) {\n        if (!arguments.length) {\n            return _useViewBoxResizing;\n        }\n        _useViewBoxResizing = useViewBoxResizing;\n        return _chart;\n    };\n\n    /**\n     * **mandatory**\n     *\n     * Set or get the dimension attribute of a chart. In `dc`, a dimension can be any valid\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter dimension}\n     *\n     * If a value is given, then it will be used as the new dimension. If no value is specified then\n     * the current dimension will be returned.\n     * @method dimension\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter.dimension}\n     * @example\n     * var index = crossfilter([]);\n     * var dimension = index.dimension(dc.pluck('key'));\n     * chart.dimension(dimension);\n     * @param {crossfilter.dimension} [dimension]\n     * @returns {crossfilter.dimension|dc.baseMixin}\n     */\n    _chart.dimension = function (dimension) {\n        if (!arguments.length) {\n            return _dimension;\n        }\n        _dimension = dimension;\n        _chart.expireCache();\n        return _chart;\n    };\n\n    /**\n     * Set the data callback or retrieve the chart's data set. The data callback is passed the chart's\n     * group and by default will return\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_all group.all}.\n     * This behavior may be modified to, for instance, return only the top 5 groups.\n     * @method data\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // Default data function\n     * chart.data(function (group) { return group.all(); });\n     *\n     * chart.data(function (group) { return group.top(5); });\n     * @param {Function} [callback]\n     * @returns {*|dc.baseMixin}\n     */\n    _chart.data = function (callback) {\n        if (!arguments.length) {\n            return _data.call(_chart, _group);\n        }\n        _data = typeof callback === 'function' ? callback : dc.utils.constant(callback);\n        _chart.expireCache();\n        return _chart;\n    };\n\n    /**\n     * **mandatory**\n     *\n     * Set or get the group attribute of a chart. In `dc` a group is a\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter group}.\n     * Usually the group should be created from the particular dimension associated with the same chart. If a value is\n     * given, then it will be used as the new group.\n     *\n     * If no value specified then the current group will be returned.\n     * If `name` is specified then it will be used to generate legend label.\n     * @method group\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter.group}\n     * @example\n     * var index = crossfilter([]);\n     * var dimension = index.dimension(dc.pluck('key'));\n     * chart.dimension(dimension);\n     * chart.group(dimension.group(crossfilter.reduceSum()));\n     * @param {crossfilter.group} [group]\n     * @param {String} [name]\n     * @returns {crossfilter.group|dc.baseMixin}\n     */\n    _chart.group = function (group, name) {\n        if (!arguments.length) {\n            return _group;\n        }\n        _group = group;\n        _chart._groupName = name;\n        _chart.expireCache();\n        return _chart;\n    };\n\n    /**\n     * Get or set an accessor to order ordinal dimensions.  The chart uses\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#quicksort_by crossfilter.quicksort.by}\n     * to sort elements; this accessor returns the value to order on.\n     * @method ordering\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#quicksort_by crossfilter.quicksort.by}\n     * @example\n     * // Default ordering accessor\n     * _chart.ordering(dc.pluck('key'));\n     * @param {Function} [orderFunction]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.ordering = function (orderFunction) {\n        if (!arguments.length) {\n            return _ordering;\n        }\n        _ordering = orderFunction;\n        _orderSort = crossfilter.quicksort.by(_ordering);\n        _chart.expireCache();\n        return _chart;\n    };\n\n    _chart._computeOrderedGroups = function (data) {\n        var dataCopy = data.slice(0);\n\n        if (dataCopy.length <= 1) {\n            return dataCopy;\n        }\n\n        if (!_orderSort) {\n            _orderSort = crossfilter.quicksort.by(_ordering);\n        }\n\n        return _orderSort(dataCopy, 0, dataCopy.length);\n    };\n\n    /**\n     * Clear all filters associated with this chart. The same effect can be achieved by calling\n     * {@link dc.baseMixin#filter chart.filter(null)}.\n     * @method filterAll\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.filterAll = function () {\n        return _chart.filter(null);\n    };\n\n    /**\n     * Execute d3 single selection in the chart's scope using the given selector and return the d3\n     * selection.\n     *\n     * This function is **not chainable** since it does not return a chart instance; however the d3\n     * selection result can be chained to d3 function calls.\n     * @method select\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3.select}\n     * @example\n     * // Has the same effect as d3.select('#chart-id').select(selector)\n     * chart.select(selector)\n     * @returns {d3.selection}\n     */\n    _chart.select = function (s) {\n        return _root.select(s);\n    };\n\n    /**\n     * Execute in scope d3 selectAll using the given selector and return d3 selection result.\n     *\n     * This function is **not chainable** since it does not return a chart instance; however the d3\n     * selection result can be chained to d3 function calls.\n     * @method selectAll\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#selectAll d3.selectAll}\n     * @example\n     * // Has the same effect as d3.select('#chart-id').selectAll(selector)\n     * chart.selectAll(selector)\n     * @returns {d3.selection}\n     */\n    _chart.selectAll = function (s) {\n        return _root ? _root.selectAll(s) : null;\n    };\n\n    /**\n     * Set the root SVGElement to either be an existing chart's root; or any valid [d3 single\n     * selector](https://github.com/d3/d3-selection/blob/master/README.md#selecting-elements) specifying a dom\n     * block element such as a div; or a dom element or d3 selection. Optionally registers the chart\n     * within the chartGroup. This class is called internally on chart initialization, but be called\n     * again to relocate the chart. However, it will orphan any previously created SVGElements.\n     * @method anchor\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {anchorChart|anchorSelector|anchorNode} [parent]\n     * @param {String} [chartGroup]\n     * @returns {String|node|d3.selection|dc.baseMixin}\n     */\n    _chart.anchor = function (parent, chartGroup) {\n        if (!arguments.length) {\n            return _anchor;\n        }\n        if (dc.instanceOfChart(parent)) {\n            _anchor = parent.anchor();\n            _root = parent.root();\n            _isChild = true;\n        } else if (parent) {\n            if (parent.select && parent.classed) { // detect d3 selection\n                _anchor = parent.node();\n            } else {\n                _anchor = parent;\n            }\n            _root = d3.select(_anchor);\n            _root.classed(dc.constants.CHART_CLASS, true);\n            dc.registerChart(_chart, chartGroup);\n            _isChild = false;\n        } else {\n            throw new dc.errors.BadArgumentException('parent must be defined');\n        }\n        _chartGroup = chartGroup;\n        return _chart;\n    };\n\n    /**\n     * Returns the DOM id for the chart's anchored location.\n     * @method anchorName\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {String}\n     */\n    _chart.anchorName = function () {\n        var a = _chart.anchor();\n        if (a && a.id) {\n            return a.id;\n        }\n        if (a && a.replace) {\n            return a.replace('#', '');\n        }\n        return 'dc-chart' + _chart.chartID();\n    };\n\n    /**\n     * Returns the root element where a chart resides. Usually it will be the parent div element where\n     * the SVGElement was created. You can also pass in a new root element however this is usually handled by\n     * dc internally. Resetting the root element on a chart outside of dc internals may have\n     * unexpected consequences.\n     * @method root\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement HTMLElement}\n     * @param {HTMLElement} [rootElement]\n     * @returns {HTMLElement|dc.baseMixin}\n     */\n    _chart.root = function (rootElement) {\n        if (!arguments.length) {\n            return _root;\n        }\n        _root = rootElement;\n        return _chart;\n    };\n\n    /**\n     * Returns the top SVGElement for this specific chart. You can also pass in a new SVGElement,\n     * however this is usually handled by dc internally. Resetting the SVGElement on a chart outside\n     * of dc internals may have unexpected consequences.\n     * @method svg\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement}\n     * @param {SVGElement|d3.selection} [svgElement]\n     * @returns {SVGElement|d3.selection|dc.baseMixin}\n     */\n    _chart.svg = function (svgElement) {\n        if (!arguments.length) {\n            return _svg;\n        }\n        _svg = svgElement;\n        return _chart;\n    };\n\n    /**\n     * Remove the chart's SVGElements from the dom and recreate the container SVGElement.\n     * @method resetSvg\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement}\n     * @returns {SVGElement}\n     */\n    _chart.resetSvg = function () {\n        _chart.select('svg').remove();\n        return generateSvg();\n    };\n\n    function sizeSvg () {\n        if (_svg) {\n            if (!_useViewBoxResizing) {\n                _svg\n                    .attr('width', _chart.width())\n                    .attr('height', _chart.height());\n            } else if (!_svg.attr('viewBox')) {\n                _svg\n                    .attr('viewBox', '0 0 ' + _chart.width() + ' ' + _chart.height());\n            }\n        }\n    }\n\n    function generateSvg () {\n        _svg = _chart.root().append('svg');\n        sizeSvg();\n        return _svg;\n    }\n\n    /**\n     * Set or get the filter printer function. The filter printer function is used to generate human\n     * friendly text for filter value(s) associated with the chart instance. The text will get shown\n     * in the `.filter element; see {@link dc.baseMixin#turnOnControls turnOnControls}.\n     *\n     * By default dc charts use a default filter printer {@link dc.printers.filters dc.printers.filters}\n     * that provides simple printing support for both single value and ranged filters.\n     * @method filterPrinter\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // for a chart with an ordinal brush, print the filters in upper case\n     * chart.filterPrinter(function(filters) {\n     *   return filters.map(function(f) { return f.toUpperCase(); }).join(', ');\n     * });\n     * // for a chart with a range brush, print the filter as start and extent\n     * chart.filterPrinter(function(filters) {\n     *   return 'start ' + dc.utils.printSingleValue(filters[0][0]) +\n     *     ' extent ' + dc.utils.printSingleValue(filters[0][1] - filters[0][0]);\n     * });\n     * @param {Function} [filterPrinterFunction=dc.printers.filters]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.filterPrinter = function (filterPrinterFunction) {\n        if (!arguments.length) {\n            return _filterPrinter;\n        }\n        _filterPrinter = filterPrinterFunction;\n        return _chart;\n    };\n\n    /**\n     * If set, use the `visibility` attribute instead of the `display` attribute for showing/hiding\n     * chart reset and filter controls, for less disruption to the layout.\n     * @method controlsUseVisibility\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Boolean} [controlsUseVisibility=false]\n     * @returns {Boolean|dc.baseMixin}\n     **/\n    _chart.controlsUseVisibility = function (useVisibility) {\n        if (!arguments.length) {\n            return _controlsUseVisibility;\n        }\n        _controlsUseVisibility = useVisibility;\n        return _chart;\n    };\n\n    /**\n     * Turn on optional control elements within the root element. dc currently supports the\n     * following html control elements.\n     * * root.selectAll('.reset') - elements are turned on if the chart has an active filter. This type\n     * of control element is usually used to store a reset link to allow user to reset filter on a\n     * certain chart. This element will be turned off automatically if the filter is cleared.\n     * * root.selectAll('.filter') elements are turned on if the chart has an active filter. The text\n     * content of this element is then replaced with the current filter value using the filter printer\n     * function. This type of element will be turned off automatically if the filter is cleared.\n     * @method turnOnControls\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.turnOnControls = function () {\n        if (_root) {\n            var attribute = _chart.controlsUseVisibility() ? 'visibility' : 'display';\n            _chart.selectAll('.reset').style(attribute, null);\n            _chart.selectAll('.filter').text(_filterPrinter(_chart.filters())).style(attribute, null);\n        }\n        return _chart;\n    };\n\n    /**\n     * Turn off optional control elements within the root element.\n     * @method turnOffControls\n     * @memberof dc.baseMixin\n     * @see {@link dc.baseMixin#turnOnControls turnOnControls}\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.turnOffControls = function () {\n        if (_root) {\n            var attribute = _chart.controlsUseVisibility() ? 'visibility' : 'display';\n            var value = _chart.controlsUseVisibility() ? 'hidden' : 'none';\n            _chart.selectAll('.reset').style(attribute, value);\n            _chart.selectAll('.filter').style(attribute, value).text(_chart.filter());\n        }\n        return _chart;\n    };\n\n    /**\n     * Set or get the animation transition duration (in milliseconds) for this chart instance.\n     * @method transitionDuration\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Number} [duration=750]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.transitionDuration = function (duration) {\n        if (!arguments.length) {\n            return _transitionDuration;\n        }\n        _transitionDuration = duration;\n        return _chart;\n    };\n\n    /**\n     * Set or get the animation transition delay (in milliseconds) for this chart instance.\n     * @method transitionDelay\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Number} [delay=0]\n     * @returns {Number|dc.baseMixin}\n     */\n    _chart.transitionDelay = function (delay) {\n        if (!arguments.length) {\n            return _transitionDelay;\n        }\n        _transitionDelay = delay;\n        return _chart;\n    };\n\n    _chart._mandatoryAttributes = function (_) {\n        if (!arguments.length) {\n            return _mandatoryAttributes;\n        }\n        _mandatoryAttributes = _;\n        return _chart;\n    };\n\n    function checkForMandatoryAttributes (a) {\n        if (!_chart[a] || !_chart[a]()) {\n            throw new dc.errors.InvalidStateException('Mandatory attribute chart.' + a +\n                ' is missing on chart[#' + _chart.anchorName() + ']');\n        }\n    }\n\n    /**\n     * Invoking this method will force the chart to re-render everything from scratch. Generally it\n     * should only be used to render the chart for the first time on the page or if you want to make\n     * sure everything is redrawn from scratch instead of relying on the default incremental redrawing\n     * behaviour.\n     * @method render\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.render = function () {\n        _height = _width = undefined; // force recalculate\n        _listeners.call('preRender', _chart, _chart);\n\n        if (_mandatoryAttributes) {\n            _mandatoryAttributes.forEach(checkForMandatoryAttributes);\n        }\n\n        var result = _chart._doRender();\n\n        if (_legend) {\n            _legend.render();\n        }\n\n        _chart._activateRenderlets('postRender');\n\n        return result;\n    };\n\n    _chart._activateRenderlets = function (event) {\n        _listeners.call('pretransition', _chart, _chart);\n        if (_chart.transitionDuration() > 0 && _svg) {\n            _svg.transition().duration(_chart.transitionDuration()).delay(_chart.transitionDelay())\n                .on('end', function () {\n                    _listeners.call('renderlet', _chart, _chart);\n                    if (event) {\n                        _listeners.call(event, _chart, _chart);\n                    }\n                });\n        } else {\n            _listeners.call('renderlet', _chart, _chart);\n            if (event) {\n                _listeners.call(event, _chart, _chart);\n            }\n        }\n    };\n\n    /**\n     * Calling redraw will cause the chart to re-render data changes incrementally. If there is no\n     * change in the underlying data dimension then calling this method will have no effect on the\n     * chart. Most chart interaction in dc will automatically trigger this method through internal\n     * events (in particular {@link dc.redrawAll dc.redrawAll}); therefore, you only need to\n     * manually invoke this function if data is manipulated outside of dc's control (for example if\n     * data is loaded in the background using\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add}).\n     * @method redraw\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.redraw = function () {\n        sizeSvg();\n        _listeners.call('preRedraw', _chart, _chart);\n\n        var result = _chart._doRedraw();\n\n        if (_legend) {\n            _legend.render();\n        }\n\n        _chart._activateRenderlets('postRedraw');\n\n        return result;\n    };\n\n    /**\n     * Gets/sets the commit handler. If the chart has a commit handler, the handler will be called when\n     * the chart's filters have changed, in order to send the filter data asynchronously to a server.\n     *\n     * Unlike other functions in dc.js, the commit handler is asynchronous. It takes two arguments:\n     * a flag indicating whether this is a render (true) or a redraw (false), and a callback to be\n     * triggered once the commit is filtered. The callback has the standard node.js continuation signature\n     * with error first and result second.\n     * @method commitHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.commitHandler = function (commitHandler) {\n        if (!arguments.length) {\n            return _commitHandler;\n        }\n        _commitHandler = commitHandler;\n        return _chart;\n    };\n\n    /**\n     * Redraws all charts in the same group as this chart, typically in reaction to a filter\n     * change. If the chart has a {@link dc.baseMixin.commitFilter commitHandler}, it will\n     * be executed and waited for.\n     * @method redrawGroup\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.redrawGroup = function () {\n        if (_commitHandler) {\n            _commitHandler(false, function (error, result) {\n                if (error) {\n                    console.log(error);\n                } else {\n                    dc.redrawAll(_chart.chartGroup());\n                }\n            });\n        } else {\n            dc.redrawAll(_chart.chartGroup());\n        }\n        return _chart;\n    };\n\n    /**\n     * Renders all charts in the same group as this chart. If the chart has a\n     * {@link dc.baseMixin.commitFilter commitHandler}, it will be executed and waited for\n     * @method renderGroup\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.renderGroup = function () {\n        if (_commitHandler) {\n            _commitHandler(false, function (error, result) {\n                if (error) {\n                    console.log(error);\n                } else {\n                    dc.renderAll(_chart.chartGroup());\n                }\n            });\n        } else {\n            dc.renderAll(_chart.chartGroup());\n        }\n        return _chart;\n    };\n\n    _chart._invokeFilteredListener = function (f) {\n        if (f !== undefined) {\n            _listeners.call('filtered', _chart, _chart, f);\n        }\n    };\n\n    _chart._invokeZoomedListener = function () {\n        _listeners.call('zoomed', _chart, _chart);\n    };\n\n    var _hasFilterHandler = function (filters, filter) {\n        if (filter === null || typeof(filter) === 'undefined') {\n            return filters.length > 0;\n        }\n        return filters.some(function (f) {\n            return filter <= f && filter >= f;\n        });\n    };\n\n    /**\n     * Set or get the has-filter handler. The has-filter handler is a function that checks to see if\n     * the chart's current filters (first argument) include a specific filter (second argument).  Using a custom has-filter handler allows\n     * you to change the way filters are checked for and replaced.\n     * @method hasFilterHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default has-filter handler\n     * chart.hasFilterHandler(function (filters, filter) {\n     *     if (filter === null || typeof(filter) === 'undefined') {\n     *         return filters.length > 0;\n     *     }\n     *     return filters.some(function (f) {\n     *         return filter <= f && filter >= f;\n     *     });\n     * });\n     *\n     * // custom filter handler (no-op)\n     * chart.hasFilterHandler(function(filters, filter) {\n     *     return false;\n     * });\n     * @param {Function} [hasFilterHandler]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.hasFilterHandler = function (hasFilterHandler) {\n        if (!arguments.length) {\n            return _hasFilterHandler;\n        }\n        _hasFilterHandler = hasFilterHandler;\n        return _chart;\n    };\n\n    /**\n     * Check whether any active filter or a specific filter is associated with particular chart instance.\n     * This function is **not chainable**.\n     * @method hasFilter\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#hasFilterHandler hasFilterHandler}\n     * @param {*} [filter]\n     * @returns {Boolean}\n     */\n    _chart.hasFilter = function (filter) {\n        return _hasFilterHandler(_filters, filter);\n    };\n\n    var _removeFilterHandler = function (filters, filter) {\n        for (var i = 0; i < filters.length; i++) {\n            if (filters[i] <= filter && filters[i] >= filter) {\n                filters.splice(i, 1);\n                break;\n            }\n        }\n        return filters;\n    };\n\n    /**\n     * Set or get the remove filter handler. The remove filter handler is a function that removes a\n     * filter from the chart's current filters. Using a custom remove filter handler allows you to\n     * change how filters are removed or perform additional work when removing a filter, e.g. when\n     * using a filter server other than crossfilter.\n     *\n     * The handler should return a new or modified array as the result.\n     * @method removeFilterHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default remove filter handler\n     * chart.removeFilterHandler(function (filters, filter) {\n     *     for (var i = 0; i < filters.length; i++) {\n     *         if (filters[i] <= filter && filters[i] >= filter) {\n     *             filters.splice(i, 1);\n     *             break;\n     *         }\n     *     }\n     *     return filters;\n     * });\n     *\n     * // custom filter handler (no-op)\n     * chart.removeFilterHandler(function(filters, filter) {\n     *     return filters;\n     * });\n     * @param {Function} [removeFilterHandler]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.removeFilterHandler = function (removeFilterHandler) {\n        if (!arguments.length) {\n            return _removeFilterHandler;\n        }\n        _removeFilterHandler = removeFilterHandler;\n        return _chart;\n    };\n\n    var _addFilterHandler = function (filters, filter) {\n        filters.push(filter);\n        return filters;\n    };\n\n    /**\n     * Set or get the add filter handler. The add filter handler is a function that adds a filter to\n     * the chart's filter list. Using a custom add filter handler allows you to change the way filters\n     * are added or perform additional work when adding a filter, e.g. when using a filter server other\n     * than crossfilter.\n     *\n     * The handler should return a new or modified array as the result.\n     * @method addFilterHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default add filter handler\n     * chart.addFilterHandler(function (filters, filter) {\n     *     filters.push(filter);\n     *     return filters;\n     * });\n     *\n     * // custom filter handler (no-op)\n     * chart.addFilterHandler(function(filters, filter) {\n     *     return filters;\n     * });\n     * @param {Function} [addFilterHandler]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.addFilterHandler = function (addFilterHandler) {\n        if (!arguments.length) {\n            return _addFilterHandler;\n        }\n        _addFilterHandler = addFilterHandler;\n        return _chart;\n    };\n\n    var _resetFilterHandler = function (filters) {\n        return [];\n    };\n\n    /**\n     * Set or get the reset filter handler. The reset filter handler is a function that resets the\n     * chart's filter list by returning a new list. Using a custom reset filter handler allows you to\n     * change the way filters are reset, or perform additional work when resetting the filters,\n     * e.g. when using a filter server other than crossfilter.\n     *\n     * The handler should return a new or modified array as the result.\n     * @method resetFilterHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default remove filter handler\n     * function (filters) {\n     *     return [];\n     * }\n     *\n     * // custom filter handler (no-op)\n     * chart.resetFilterHandler(function(filters) {\n     *     return filters;\n     * });\n     * @param {Function} [resetFilterHandler]\n     * @returns {dc.baseMixin}\n     */\n    _chart.resetFilterHandler = function (resetFilterHandler) {\n        if (!arguments.length) {\n            return _resetFilterHandler;\n        }\n        _resetFilterHandler = resetFilterHandler;\n        return _chart;\n    };\n\n    function applyFilters (filters) {\n        if (_chart.dimension() && _chart.dimension().filter) {\n            var fs = _filterHandler(_chart.dimension(), filters);\n            if (fs) {\n                filters = fs;\n            }\n        }\n        return filters;\n    }\n\n    /**\n     * Replace the chart filter. This is equivalent to calling `chart.filter(null).filter(filter)`\n     * but more efficient because the filter is only applied once.\n     *\n     * @method replaceFilter\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {*} [filter]\n     * @returns {dc.baseMixin}\n     **/\n    _chart.replaceFilter = function (filter) {\n        _filters = _resetFilterHandler(_filters);\n        _chart.filter(filter);\n        return _chart;\n    };\n\n    /**\n     * Filter the chart by the given parameter, or return the current filter if no input parameter\n     * is given.\n     *\n     * The filter parameter can take one of these forms:\n     * * A single value: the value will be toggled (added if it is not present in the current\n     * filters, removed if it is present)\n     * * An array containing a single array of values (`[[value,value,value]]`): each value is\n     * toggled\n     * * When appropriate for the chart, a {@link dc.filters dc filter object} such as\n     *   * {@link dc.filters.RangedFilter `dc.filters.RangedFilter`} for the\n     * {@link dc.coordinateGridMixin dc.coordinateGridMixin} charts\n     *   * {@link dc.filters.TwoDimensionalFilter `dc.filters.TwoDimensionalFilter`} for the\n     * {@link dc.heatMap heat map}\n     *   * {@link dc.filters.RangedTwoDimensionalFilter `dc.filters.RangedTwoDimensionalFilter`}\n     * for the {@link dc.scatterPlot scatter plot}\n     * * `null`: the filter will be reset using the\n     * {@link dc.baseMixin#resetFilterHandler resetFilterHandler}\n     *\n     * Note that this is always a toggle (even when it doesn't make sense for the filter type). If\n     * you wish to replace the current filter, either call `chart.filter(null)` first - or it's more\n     * efficient to call {@link dc.baseMixin#replaceFilter `chart.replaceFilter(filter)`} instead.\n     *\n     * Each toggle is executed by checking if the value is already present using the\n     * {@link dc.baseMixin#hasFilterHandler hasFilterHandler}; if it is not present, it is added\n     * using the {@link dc.baseMixin#addFilterHandler addFilterHandler}; if it is already present,\n     * it is removed using the {@link dc.baseMixin#removeFilterHandler removeFilterHandler}.\n     *\n     * Once the filters array has been updated, the filters are applied to the\n     * crossfilter dimension, using the {@link dc.baseMixin#filterHandler filterHandler}.\n     *\n     * Once you have set the filters, call {@link dc.baseMixin#redrawGroup `chart.redrawGroup()`}\n     * (or {@link dc.redrawAll `dc.redrawAll()`}) to redraw the chart's group.\n     * @method filter\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link dc.baseMixin#addFilterHandler addFilterHandler}\n     * @see {@link dc.baseMixin#removeFilterHandler removeFilterHandler}\n     * @see {@link dc.baseMixin#resetFilterHandler resetFilterHandler}\n     * @see {@link dc.baseMixin#filterHandler filterHandler}\n     * @example\n     * // filter by a single string\n     * chart.filter('Sunday');\n     * // filter by a single age\n     * chart.filter(18);\n     * // filter by a set of states\n     * chart.filter([['MA', 'TX', 'ND', 'WA']]);\n     * // filter by range -- note the use of dc.filters.RangedFilter, which is different\n     * // from the syntax for filtering a crossfilter dimension directly, dimension.filter([15,20])\n     * chart.filter(dc.filters.RangedFilter(15,20));\n     * @param {*} [filter]\n     * @returns {dc.baseMixin}\n     */\n    _chart.filter = function (filter) {\n        if (!arguments.length) {\n            return _filters.length > 0 ? _filters[0] : null;\n        }\n        var filters = _filters;\n        if (filter instanceof Array && filter[0] instanceof Array && !filter.isFiltered) {\n            // toggle each filter\n            filter[0].forEach(function (f) {\n                if (_hasFilterHandler(filters, f)) {\n                    filters = _removeFilterHandler(filters, f);\n                } else {\n                    filters = _addFilterHandler(filters, f);\n                }\n            });\n        } else if (filter === null) {\n            filters = _resetFilterHandler(filters);\n        } else {\n            if (_hasFilterHandler(filters, filter)) {\n                filters = _removeFilterHandler(filters, filter);\n            } else {\n                filters = _addFilterHandler(filters, filter);\n            }\n        }\n        _filters = applyFilters(filters);\n        _chart._invokeFilteredListener(filter);\n\n        if (_root !== null && _chart.hasFilter()) {\n            _chart.turnOnControls();\n        } else {\n            _chart.turnOffControls();\n        }\n\n        return _chart;\n    };\n\n    /**\n     * Returns all current filters. This method does not perform defensive cloning of the internal\n     * filter array before returning, therefore any modification of the returned array will effect the\n     * chart's internal filter storage.\n     * @method filters\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {Array<*>}\n     */\n    _chart.filters = function () {\n        return _filters;\n    };\n\n    _chart.highlightSelected = function (e) {\n        d3.select(e).classed(dc.constants.SELECTED_CLASS, true);\n        d3.select(e).classed(dc.constants.DESELECTED_CLASS, false);\n    };\n\n    _chart.fadeDeselected = function (e) {\n        d3.select(e).classed(dc.constants.SELECTED_CLASS, false);\n        d3.select(e).classed(dc.constants.DESELECTED_CLASS, true);\n    };\n\n    _chart.resetHighlight = function (e) {\n        d3.select(e).classed(dc.constants.SELECTED_CLASS, false);\n        d3.select(e).classed(dc.constants.DESELECTED_CLASS, false);\n    };\n\n    /**\n     * This function is passed to d3 as the onClick handler for each chart. The default behavior is to\n     * filter on the clicked datum (passed to the callback) and redraw the chart group.\n     * @method onClick\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {*} datum\n     */\n    _chart.onClick = function (datum) {\n        var filter = _chart.keyAccessor()(datum);\n        dc.events.trigger(function () {\n            _chart.filter(filter);\n            _chart.redrawGroup();\n        });\n    };\n\n    /**\n     * Set or get the filter handler. The filter handler is a function that performs the filter action\n     * on a specific dimension. Using a custom filter handler allows you to perform additional logic\n     * before or after filtering.\n     * @method filterHandler\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter crossfilter.dimension.filter}\n     * @example\n     * // the default filter handler handles all possible cases for the charts in dc.js\n     * // you can replace it with something more specialized for your own chart\n     * chart.filterHandler(function (dimension, filters) {\n     *     if (filters.length === 0) {\n     *         // the empty case (no filtering)\n     *         dimension.filter(null);\n     *     } else if (filters.length === 1 && !filters[0].isFiltered) {\n     *         // single value and not a function-based filter\n     *         dimension.filterExact(filters[0]);\n     *     } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') {\n     *         // single range-based filter\n     *         dimension.filterRange(filters[0]);\n     *     } else {\n     *         // an array of values, or an array of filter objects\n     *         dimension.filterFunction(function (d) {\n     *             for (var i = 0; i < filters.length; i++) {\n     *                 var filter = filters[i];\n     *                 if (filter.isFiltered && filter.isFiltered(d)) {\n     *                     return true;\n     *                 } else if (filter <= d && filter >= d) {\n     *                     return true;\n     *                 }\n     *             }\n     *             return false;\n     *         });\n     *     }\n     *     return filters;\n     * });\n     *\n     * // custom filter handler\n     * chart.filterHandler(function(dimension, filter){\n     *     var newFilter = filter + 10;\n     *     dimension.filter(newFilter);\n     *     return newFilter; // set the actual filter value to the new value\n     * });\n     * @param {Function} [filterHandler]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.filterHandler = function (filterHandler) {\n        if (!arguments.length) {\n            return _filterHandler;\n        }\n        _filterHandler = filterHandler;\n        return _chart;\n    };\n\n    // abstract function stub\n    _chart._doRender = function () {\n        // do nothing in base, should be overridden by sub-function\n        return _chart;\n    };\n\n    _chart._doRedraw = function () {\n        // do nothing in base, should be overridden by sub-function\n        return _chart;\n    };\n\n    _chart.legendables = function () {\n        // do nothing in base, should be overridden by sub-function\n        return [];\n    };\n\n    _chart.legendHighlight = function () {\n        // do nothing in base, should be overridden by sub-function\n    };\n\n    _chart.legendReset = function () {\n        // do nothing in base, should be overridden by sub-function\n    };\n\n    _chart.legendToggle = function () {\n        // do nothing in base, should be overriden by sub-function\n    };\n\n    _chart.isLegendableHidden = function () {\n        // do nothing in base, should be overridden by sub-function\n        return false;\n    };\n\n    /**\n     * Set or get the key accessor function. The key accessor function is used to retrieve the key\n     * value from the crossfilter group. Key values are used differently in different charts, for\n     * example keys correspond to slices in a pie chart and x axis positions in a grid coordinate chart.\n     * @method keyAccessor\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default key accessor\n     * chart.keyAccessor(function(d) { return d.key; });\n     * // custom key accessor for a multi-value crossfilter reduction\n     * chart.keyAccessor(function(p) { return p.value.absGain; });\n     * @param {Function} [keyAccessor]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.keyAccessor = function (keyAccessor) {\n        if (!arguments.length) {\n            return _keyAccessor;\n        }\n        _keyAccessor = keyAccessor;\n        return _chart;\n    };\n\n    /**\n     * Set or get the value accessor function. The value accessor function is used to retrieve the\n     * value from the crossfilter group. Group values are used differently in different charts, for\n     * example values correspond to slice sizes in a pie chart and y axis positions in a grid\n     * coordinate chart.\n     * @method valueAccessor\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default value accessor\n     * chart.valueAccessor(function(d) { return d.value; });\n     * // custom value accessor for a multi-value crossfilter reduction\n     * chart.valueAccessor(function(p) { return p.value.percentageGain; });\n     * @param {Function} [valueAccessor]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.valueAccessor = function (valueAccessor) {\n        if (!arguments.length) {\n            return _valueAccessor;\n        }\n        _valueAccessor = valueAccessor;\n        return _chart;\n    };\n\n    /**\n     * Set or get the label function. The chart class will use this function to render labels for each\n     * child element in the chart, e.g. slices in a pie chart or bubbles in a bubble chart. Not every\n     * chart supports the label function, for example line chart does not use this function\n     * at all. By default, enables labels; pass false for the second parameter if this is not desired.\n     * @method label\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default label function just return the key\n     * chart.label(function(d) { return d.key; });\n     * // label function has access to the standard d3 data binding and can get quite complicated\n     * chart.label(function(d) { return d.data.key + '(' + Math.floor(d.data.value / all.value() * 100) + '%)'; });\n     * @param {Function} [labelFunction]\n     * @param {Boolean} [enableLabels=true]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.label = function (labelFunction, enableLabels) {\n        if (!arguments.length) {\n            return _label;\n        }\n        _label = labelFunction;\n        if ((enableLabels === undefined) || enableLabels) {\n            _renderLabel = true;\n        }\n        return _chart;\n    };\n\n    /**\n     * Turn on/off label rendering\n     * @method renderLabel\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Boolean} [renderLabel=false]\n     * @returns {Boolean|dc.baseMixin}\n     */\n    _chart.renderLabel = function (renderLabel) {\n        if (!arguments.length) {\n            return _renderLabel;\n        }\n        _renderLabel = renderLabel;\n        return _chart;\n    };\n\n    /**\n     * Set or get the title function. The chart class will use this function to render the SVGElement title\n     * (usually interpreted by browser as tooltips) for each child element in the chart, e.g. a slice\n     * in a pie chart or a bubble in a bubble chart. Almost every chart supports the title function;\n     * however in grid coordinate charts you need to turn off the brush in order to see titles, because\n     * otherwise the brush layer will block tooltip triggering.\n     * @method title\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * // default title function shows \"key: value\"\n     * chart.title(function(d) { return d.key + ': ' + d.value; });\n     * // title function has access to the standard d3 data binding and can get quite complicated\n     * chart.title(function(p) {\n     *    return p.key.getFullYear()\n     *        + '\\n'\n     *        + 'Index Gain: ' + numberFormat(p.value.absGain) + '\\n'\n     *        + 'Index Gain in Percentage: ' + numberFormat(p.value.percentageGain) + '%\\n'\n     *        + 'Fluctuation / Index Ratio: ' + numberFormat(p.value.fluctuationPercentage) + '%';\n     * });\n     * @param {Function} [titleFunction]\n     * @returns {Function|dc.baseMixin}\n     */\n    _chart.title = function (titleFunction) {\n        if (!arguments.length) {\n            return _title;\n        }\n        _title = titleFunction;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off title rendering, or return the state of the render title flag if no arguments are\n     * given.\n     * @method renderTitle\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {Boolean} [renderTitle=true]\n     * @returns {Boolean|dc.baseMixin}\n     */\n    _chart.renderTitle = function (renderTitle) {\n        if (!arguments.length) {\n            return _renderTitle;\n        }\n        _renderTitle = renderTitle;\n        return _chart;\n    };\n\n    /**\n     * A renderlet is similar to an event listener on rendering event. Multiple renderlets can be added\n     * to an individual chart.  Each time a chart is rerendered or redrawn the renderlets are invoked\n     * right after the chart finishes its transitions, giving you a way to modify the SVGElements.\n     * Renderlet functions take the chart instance as the only input parameter and you can\n     * use the dc API or use raw d3 to achieve pretty much any effect.\n     *\n     * Use {@link dc.baseMixin#on on} with a 'renderlet' prefix.\n     * Generates a random key for the renderlet, which makes it hard to remove.\n     * @method renderlet\n     * @memberof dc.baseMixin\n     * @instance\n     * @deprecated\n     * @example\n     * // do this instead of .renderlet(function(chart) { ... })\n     * chart.on(\"renderlet\", function(chart){\n     *     // mix of dc API and d3 manipulation\n     *     chart.select('g.y').style('display', 'none');\n     *     // its a closure so you can also access other chart variable available in the closure scope\n     *     moveChart.filter(chart.filter());\n     * });\n     * @param {Function} renderletFunction\n     * @returns {dc.baseMixin}\n     */\n    _chart.renderlet = dc.logger.deprecate(function (renderletFunction) {\n        _chart.on('renderlet.' + dc.utils.uniqueId(), renderletFunction);\n        return _chart;\n    }, 'chart.renderlet has been deprecated.  Please use chart.on(\"renderlet.<renderletKey>\", renderletFunction)');\n\n    /**\n     * Get or set the chart group to which this chart belongs. Chart groups are rendered or redrawn\n     * together since it is expected they share the same underlying crossfilter data set.\n     * @method chartGroup\n     * @memberof dc.baseMixin\n     * @instance\n     * @param {String} [chartGroup]\n     * @returns {String|dc.baseMixin}\n     */\n    _chart.chartGroup = function (chartGroup) {\n        if (!arguments.length) {\n            return _chartGroup;\n        }\n        if (!_isChild) {\n            dc.deregisterChart(_chart, _chartGroup);\n        }\n        _chartGroup = chartGroup;\n        if (!_isChild) {\n            dc.registerChart(_chart, _chartGroup);\n        }\n        return _chart;\n    };\n\n    /**\n     * Expire the internal chart cache. dc charts cache some data internally on a per chart basis to\n     * speed up rendering and avoid unnecessary calculation; however it might be useful to clear the\n     * cache if you have changed state which will affect rendering.  For example, if you invoke\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add}\n     * function or reset group or dimension after rendering, it is a good idea to\n     * clear the cache to make sure charts are rendered properly.\n     * @method expireCache\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {dc.baseMixin}\n     */\n    _chart.expireCache = function () {\n        // do nothing in base, should be overridden by sub-function\n        return _chart;\n    };\n\n    /**\n     * Attach a dc.legend widget to this chart. The legend widget will automatically draw legend labels\n     * based on the color setting and names associated with each group.\n     * @method legend\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * chart.legend(dc.legend().x(400).y(10).itemHeight(13).gap(5))\n     * @param {dc.legend} [legend]\n     * @returns {dc.legend|dc.baseMixin}\n     */\n    _chart.legend = function (legend) {\n        if (!arguments.length) {\n            return _legend;\n        }\n        _legend = legend;\n        _legend.parent(_chart);\n        return _chart;\n    };\n\n    /**\n     * Returns the internal numeric ID of the chart.\n     * @method chartID\n     * @memberof dc.baseMixin\n     * @instance\n     * @returns {String}\n     */\n    _chart.chartID = function () {\n        return _chart.__dcFlag__;\n    };\n\n    /**\n     * Set chart options using a configuration object. Each key in the object will cause the method of\n     * the same name to be called with the value to set that attribute for the chart.\n     * @method options\n     * @memberof dc.baseMixin\n     * @instance\n     * @example\n     * chart.options({dimension: myDimension, group: myGroup});\n     * @param {{}} opts\n     * @returns {dc.baseMixin}\n     */\n    _chart.options = function (opts) {\n        var applyOptions = [\n            'anchor',\n            'group',\n            'xAxisLabel',\n            'yAxisLabel',\n            'stack',\n            'title',\n            'point',\n            'getColor',\n            'overlayGeoJson'\n        ];\n\n        for (var o in opts) {\n            if (typeof(_chart[o]) === 'function') {\n                if (opts[o] instanceof Array && applyOptions.indexOf(o) !== -1) {\n                    _chart[o].apply(_chart, opts[o]);\n                } else {\n                    _chart[o].call(_chart, opts[o]);\n                }\n            } else {\n                dc.logger.debug('Not a valid option setter name: ' + o);\n            }\n        }\n        return _chart;\n    };\n\n    /**\n     * All dc chart instance supports the following listeners.\n     * Supports the following events:\n     * * `renderlet` - This listener function will be invoked after transitions after redraw and render. Replaces the\n     * deprecated {@link dc.baseMixin#renderlet renderlet} method.\n     * * `pretransition` - Like `.on('renderlet', ...)` but the event is fired before transitions start.\n     * * `preRender` - This listener function will be invoked before chart rendering.\n     * * `postRender` - This listener function will be invoked after chart finish rendering including\n     * all renderlets' logic.\n     * * `preRedraw` - This listener function will be invoked before chart redrawing.\n     * * `postRedraw` - This listener function will be invoked after chart finish redrawing\n     * including all renderlets' logic.\n     * * `filtered` - This listener function will be invoked after a filter is applied, added or removed.\n     * * `zoomed` - This listener function will be invoked after a zoom is triggered.\n     * @method on\n     * @memberof dc.baseMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-dispatch/blob/master/README.md#dispatch_on d3.dispatch.on}\n     * @example\n     * .on('renderlet', function(chart, filter){...})\n     * .on('pretransition', function(chart, filter){...})\n     * .on('preRender', function(chart){...})\n     * .on('postRender', function(chart){...})\n     * .on('preRedraw', function(chart){...})\n     * .on('postRedraw', function(chart){...})\n     * .on('filtered', function(chart, filter){...})\n     * .on('zoomed', function(chart, filter){...})\n     * @param {String} event\n     * @param {Function} listener\n     * @returns {dc.baseMixin}\n     */\n    _chart.on = function (event, listener) {\n        _listeners.on(event, listener);\n        return _chart;\n    };\n\n    return _chart;\n};\n\n/**\n * Margin is a mixin that provides margin utility functions for both the Row Chart and Coordinate Grid\n * Charts.\n * @name marginMixin\n * @memberof dc\n * @mixin\n * @param {Object} _chart\n * @returns {dc.marginMixin}\n */\ndc.marginMixin = function (_chart) {\n    var _margin = {top: 10, right: 50, bottom: 30, left: 30};\n\n    /**\n     * Get or set the margins for a particular coordinate grid chart instance. The margins is stored as\n     * an associative Javascript array.\n     * @method margins\n     * @memberof dc.marginMixin\n     * @instance\n     * @example\n     * var leftMargin = chart.margins().left; // 30 by default\n     * chart.margins().left = 50;\n     * leftMargin = chart.margins().left; // now 50\n     * @param {{top: Number, right: Number, left: Number, bottom: Number}} [margins={top: 10, right: 50, bottom: 30, left: 30}]\n     * @returns {{top: Number, right: Number, left: Number, bottom: Number}|dc.marginMixin}\n     */\n    _chart.margins = function (margins) {\n        if (!arguments.length) {\n            return _margin;\n        }\n        _margin = margins;\n        return _chart;\n    };\n\n    _chart.effectiveWidth = function () {\n        return _chart.width() - _chart.margins().left - _chart.margins().right;\n    };\n\n    _chart.effectiveHeight = function () {\n        return _chart.height() - _chart.margins().top - _chart.margins().bottom;\n    };\n\n    return _chart;\n};\n\n/**\n * The Color Mixin is an abstract chart functional class providing universal coloring support\n * as a mix-in for any concrete chart implementation.\n * @name colorMixin\n * @memberof dc\n * @mixin\n * @param {Object} _chart\n * @returns {dc.colorMixin}\n */\ndc.colorMixin = function (_chart) {\n    var _colors = d3.scaleOrdinal(dc.config.defaultColors());\n    var _defaultAccessor = true;\n\n    var _colorAccessor = function (d) { return _chart.keyAccessor()(d); };\n\n    /**\n     * Retrieve current color scale or set a new color scale. This methods accepts any function that\n     * operates like a d3 scale.\n     * @method colors\n     * @memberof dc.colorMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @example\n     * // alternate categorical scale\n     * chart.colors(d3.scale.category20b());\n     * // ordinal scale\n     * chart.colors(d3.scaleOrdinal().range(['red','green','blue']));\n     * // convenience method, the same as above\n     * chart.ordinalColors(['red','green','blue']);\n     * // set a linear scale\n     * chart.linearColors([\"#4575b4\", \"#ffffbf\", \"#a50026\"]);\n     * @param {d3.scale} [colorScale=d3.scaleOrdinal(d3.schemeCategory20c)]\n     * @returns {d3.scale|dc.colorMixin}\n     */\n    _chart.colors = function (colorScale) {\n        if (!arguments.length) {\n            return _colors;\n        }\n        if (colorScale instanceof Array) {\n            _colors = d3.scaleQuantize().range(colorScale); // deprecated legacy support, note: this fails for ordinal domains\n        } else {\n            _colors = typeof colorScale === 'function' ? colorScale : dc.utils.constant(colorScale);\n        }\n        return _chart;\n    };\n\n    /**\n     * Convenience method to set the color scale to\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal} with\n     * range `r`.\n     * @method ordinalColors\n     * @memberof dc.colorMixin\n     * @instance\n     * @param {Array<String>} r\n     * @returns {dc.colorMixin}\n     */\n    _chart.ordinalColors = function (r) {\n        return _chart.colors(d3.scaleOrdinal().range(r));\n    };\n\n    /**\n     * Convenience method to set the color scale to an Hcl interpolated linear scale with range `r`.\n     * @method linearColors\n     * @memberof dc.colorMixin\n     * @instance\n     * @param {Array<Number>} r\n     * @returns {dc.colorMixin}\n     */\n    _chart.linearColors = function (r) {\n        return _chart.colors(d3.scaleLinear()\n                             .range(r)\n                             .interpolate(d3.interpolateHcl));\n    };\n\n    /**\n     * Set or the get color accessor function. This function will be used to map a data point in a\n     * crossfilter group to a color value on the color scale. The default function uses the key\n     * accessor.\n     * @method colorAccessor\n     * @memberof dc.colorMixin\n     * @instance\n     * @example\n     * // default index based color accessor\n     * .colorAccessor(function (d, i){return i;})\n     * // color accessor for a multi-value crossfilter reduction\n     * .colorAccessor(function (d){return d.value.absGain;})\n     * @param {Function} [colorAccessor]\n     * @returns {Function|dc.colorMixin}\n     */\n    _chart.colorAccessor = function (colorAccessor) {\n        if (!arguments.length) {\n            return _colorAccessor;\n        }\n        _colorAccessor = colorAccessor;\n        _defaultAccessor = false;\n        return _chart;\n    };\n\n    // what is this?\n    _chart.defaultColorAccessor = function () {\n        return _defaultAccessor;\n    };\n\n    /**\n     * Set or get the current domain for the color mapping function. The domain must be supplied as an\n     * array.\n     *\n     * Note: previously this method accepted a callback function. Instead you may use a custom scale\n     * set by {@link dc.colorMixin#colors .colors}.\n     * @method colorDomain\n     * @memberof dc.colorMixin\n     * @instance\n     * @param {Array<String>} [domain]\n     * @returns {Array<String>|dc.colorMixin}\n     */\n    _chart.colorDomain = function (domain) {\n        if (!arguments.length) {\n            return _colors.domain();\n        }\n        _colors.domain(domain);\n        return _chart;\n    };\n\n    /**\n     * Set the domain by determining the min and max values as retrieved by\n     * {@link dc.colorMixin#colorAccessor .colorAccessor} over the chart's dataset.\n     * @method calculateColorDomain\n     * @memberof dc.colorMixin\n     * @instance\n     * @returns {dc.colorMixin}\n     */\n    _chart.calculateColorDomain = function () {\n        var newDomain = [d3.min(_chart.data(), _chart.colorAccessor()),\n                         d3.max(_chart.data(), _chart.colorAccessor())];\n        _colors.domain(newDomain);\n        return _chart;\n    };\n\n    /**\n     * Get the color for the datum d and counter i. This is used internally by charts to retrieve a color.\n     * @method getColor\n     * @memberof dc.colorMixin\n     * @instance\n     * @param {*} d\n     * @param {Number} [i]\n     * @returns {String}\n     */\n    _chart.getColor = function (d, i) {\n        return _colors(_colorAccessor.call(this, d, i));\n    };\n\n    /**\n     * **Deprecated.** Get/set the color calculator. This actually replaces the\n     * {@link dc.colorMixin#getColor getColor} method!\n     *\n     * This is not recommended, since using a {@link dc.colorMixin#colorAccessor colorAccessor} and\n     * color scale ({@link dc.colorMixin#colors .colors}) is more powerful and idiomatic d3.\n     * @method colorCalculator\n     * @memberof dc.colorMixin\n     * @instance\n     * @param {*} [colorCalculator]\n     * @returns {Function|dc.colorMixin}\n     */\n    _chart.colorCalculator = dc.logger.deprecate(function (colorCalculator) {\n        if (!arguments.length) {\n            return _chart.getColor;\n        }\n        _chart.getColor = colorCalculator;\n        return _chart;\n    }, 'colorMixin.colorCalculator has been deprecated. Please colorMixin.colors and colorMixin.colorAccessor instead');\n\n    return _chart;\n};\n\n/**\n * Coordinate Grid is an abstract base chart designed to support a number of coordinate grid based\n * concrete chart types, e.g. bar chart, line chart, and bubble chart.\n * @name coordinateGridMixin\n * @memberof dc\n * @mixin\n * @mixes dc.colorMixin\n * @mixes dc.marginMixin\n * @mixes dc.baseMixin\n * @param {Object} _chart\n * @returns {dc.coordinateGridMixin}\n */\ndc.coordinateGridMixin = function (_chart) {\n    var GRID_LINE_CLASS = 'grid-line';\n    var HORIZONTAL_CLASS = 'horizontal';\n    var VERTICAL_CLASS = 'vertical';\n    var Y_AXIS_LABEL_CLASS = 'y-axis-label';\n    var X_AXIS_LABEL_CLASS = 'x-axis-label';\n    var CUSTOM_BRUSH_HANDLE_CLASS = 'custom-brush-handle';\n    var DEFAULT_AXIS_LABEL_PADDING = 12;\n\n    _chart = dc.colorMixin(dc.marginMixin(dc.baseMixin(_chart)));\n\n    _chart.colors(d3.scaleOrdinal(d3.schemeCategory10));\n    _chart._mandatoryAttributes().push('x');\n    var _parent;\n    var _g;\n    var _chartBodyG;\n\n    var _x;\n    var _origX; // Will hold orginial scale in case of zoom\n    var _xOriginalDomain;\n    var _xAxis = d3.axisBottom();\n    var _xUnits = dc.units.integers;\n    var _xAxisPadding = 0;\n    var _xAxisPaddingUnit = d3.timeDay;\n    var _xElasticity = false;\n    var _xAxisLabel;\n    var _xAxisLabelPadding = 0;\n    var _lastXDomain;\n\n    var _y;\n    var _yAxis = null;\n    var _yAxisPadding = 0;\n    var _yElasticity = false;\n    var _yAxisLabel;\n    var _yAxisLabelPadding = 0;\n\n    var _brush = d3.brushX();\n    var _gBrush;\n    var _brushOn = true;\n    var _parentBrushOn = false;\n    var _round;\n\n    var _renderHorizontalGridLine = false;\n    var _renderVerticalGridLine = false;\n\n    var _resizing = false;\n    var _unitCount;\n\n    var _zoomScale = [1, Infinity];\n    var _zoomOutRestrict = true;\n\n    var _zoom = d3.zoom().on('zoom', onZoom);\n    var _nullZoom = d3.zoom().on('zoom', null);\n    var _hasBeenMouseZoomable = false;\n\n    var _rangeChart;\n    var _focusChart;\n\n    var _mouseZoomable = false;\n    var _clipPadding = 0;\n\n    var _outerRangeBandPadding = 0.5;\n    var _rangeBandPadding = 0;\n\n    var _useRightYAxis = false;\n\n    /**\n     * When changing the domain of the x or y scale, it is necessary to tell the chart to recalculate\n     * and redraw the axes. (`.rescale()` is called automatically when the x or y scale is replaced\n     * with {@link dc.coordinateGridMixin+x .x()} or {@link dc.coordinateGridMixin#y .y()}, and has\n     * no effect on elastic scales.)\n     * @method rescale\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {dc.coordinateGridMixin}\n     */\n    _chart.rescale = function () {\n        _unitCount = undefined;\n        _resizing = true;\n        return _chart;\n    };\n\n    _chart.resizing = function () {\n        return _resizing;\n    };\n\n    /**\n     * Get or set the range selection chart associated with this instance. Setting the range selection\n     * chart using this function will automatically update its selection brush when the current chart\n     * zooms in. In return the given range chart will also automatically attach this chart as its focus\n     * chart hence zoom in when range brush updates.\n     *\n     * Usually the range and focus charts will share a dimension. The range chart will set the zoom\n     * boundaries for the focus chart, so its dimension values must be compatible with the domain of\n     * the focus chart.\n     *\n     * See the [Nasdaq 100 Index](http://dc-js.github.com/dc.js/) example for this effect in action.\n     * @method rangeChart\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {dc.coordinateGridMixin} [rangeChart]\n     * @returns {dc.coordinateGridMixin}\n     */\n    _chart.rangeChart = function (rangeChart) {\n        if (!arguments.length) {\n            return _rangeChart;\n        }\n        _rangeChart = rangeChart;\n        _rangeChart.focusChart(_chart);\n        return _chart;\n    };\n\n    /**\n     * Get or set the scale extent for mouse zooms.\n     * @method zoomScale\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Array<Number|Date>} [extent=[1, Infinity]]\n     * @returns {Array<Number|Date>|dc.coordinateGridMixin}\n     */\n    _chart.zoomScale = function (extent) {\n        if (!arguments.length) {\n            return _zoomScale;\n        }\n        _zoomScale = extent;\n        return _chart;\n    };\n\n    /**\n     * Get or set the zoom restriction for the chart. If true limits the zoom to origional domain of the chart.\n     * @method zoomOutRestrict\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [zoomOutRestrict=true]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.zoomOutRestrict = function (zoomOutRestrict) {\n        if (!arguments.length) {\n            return _zoomOutRestrict;\n        }\n        _zoomOutRestrict = zoomOutRestrict;\n        return _chart;\n    };\n\n    _chart._generateG = function (parent) {\n        if (parent === undefined) {\n            _parent = _chart.svg();\n        } else {\n            _parent = parent;\n        }\n\n        var href = window.location.href.split('#')[0];\n\n        _g = _parent.append('g');\n\n        _chartBodyG = _g.append('g').attr('class', 'chart-body')\n            .attr('transform', 'translate(' + _chart.margins().left + ', ' + _chart.margins().top + ')')\n            .attr('clip-path', 'url(' + href + '#' + getClipPathId() + ')');\n\n        return _g;\n    };\n\n    /**\n     * Get or set the root g element. This method is usually used to retrieve the g element in order to\n     * overlay custom svg drawing programatically. **Caution**: The root g element is usually generated\n     * by dc.js internals, and resetting it might produce unpredictable result.\n     * @method g\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {SVGElement} [gElement]\n     * @returns {SVGElement|dc.coordinateGridMixin}\n     */\n    _chart.g = function (gElement) {\n        if (!arguments.length) {\n            return _g;\n        }\n        _g = gElement;\n        return _chart;\n    };\n\n    /**\n     * Set or get mouse zoom capability flag (default: false). When turned on the chart will be\n     * zoomable using the mouse wheel. If the range selector chart is attached zooming will also update\n     * the range selection brush on the associated range selector chart.\n     * @method mouseZoomable\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [mouseZoomable=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.mouseZoomable = function (mouseZoomable) {\n        if (!arguments.length) {\n            return _mouseZoomable;\n        }\n        _mouseZoomable = mouseZoomable;\n        return _chart;\n    };\n\n    /**\n     * Retrieve the svg group for the chart body.\n     * @method chartBodyG\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {SVGElement} [chartBodyG]\n     * @returns {SVGElement}\n     */\n    _chart.chartBodyG = function (chartBodyG) {\n        if (!arguments.length) {\n            return _chartBodyG;\n        }\n        _chartBodyG = chartBodyG;\n        return _chart;\n    };\n\n    /**\n     * **mandatory**\n     *\n     * Get or set the x scale. The x scale can be any d3\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} or\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales ordinal scale}\n     * @method x\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @example\n     * // set x to a linear scale\n     * chart.x(d3.scaleLinear().domain([-2500, 2500]))\n     * // set x to a time scale to generate histogram\n     * chart.x(d3.scaleTime().domain([new Date(1985, 0, 1), new Date(2012, 11, 31)]))\n     * @param {d3.scale} [xScale]\n     * @returns {d3.scale|dc.coordinateGridMixin}\n     */\n    _chart.x = function (xScale) {\n        if (!arguments.length) {\n            return _x;\n        }\n        _x = xScale;\n        _xOriginalDomain = _x.domain();\n        _chart.rescale();\n        return _chart;\n    };\n\n    _chart.xOriginalDomain = function () {\n        return _xOriginalDomain;\n    };\n\n    /**\n     * Set or get the xUnits function. The coordinate grid chart uses the xUnits function to calculate\n     * the number of data projections on the x axis such as the number of bars for a bar chart or the\n     * number of dots for a line chart.\n     *\n     * This function is expected to return a Javascript array of all data points on the x axis, or\n     * the number of points on the axis. d3 time range functions [d3.timeDays, d3.timeMonths, and\n     * d3.timeYears](https://github.com/d3/d3-time/blob/master/README.md#intervals) are all valid\n     * xUnits functions.\n     *\n     * dc.js also provides a few units function, see the {@link dc.units Units Namespace} for\n     * a list of built-in units functions.\n     *\n     * Note that as of dc.js 3.0, `dc.units.ordinal` is not a real function, because it is not\n     * possible to define this function compliant with the d3 range functions. It was already a\n     * magic value which caused charts to behave differently, and now it is completely so.\n     * @method xUnits\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @example\n     * // set x units to count days\n     * chart.xUnits(d3.timeDays);\n     * // set x units to count months\n     * chart.xUnits(d3.timeMonths);\n     *\n     * // A custom xUnits function can be used as long as it follows the following interface:\n     * // units in integer\n     * function(start, end) {\n     *      // simply calculates how many integers in the domain\n     *      return Math.abs(end - start);\n     * }\n     *\n     * // fixed units\n     * function(start, end) {\n     *      // be aware using fixed units will disable the focus/zoom ability on the chart\n     *      return 1000;\n     * }\n     * @param {Function} [xUnits=dc.units.integers]\n     * @returns {Function|dc.coordinateGridMixin}\n     */\n    _chart.xUnits = function (xUnits) {\n        if (!arguments.length) {\n            return _xUnits;\n        }\n        _xUnits = xUnits;\n        return _chart;\n    };\n\n    /**\n     * Set or get the x axis used by a particular coordinate grid chart instance. This function is most\n     * useful when x axis customization is required. The x axis in dc.js is an instance of a\n     * {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3 bottom axis object};\n     * therefore it supports any valid d3 axisBottom manipulation.\n     *\n     * **Caution**: The x axis is usually generated internally by dc; resetting it may cause\n     * unexpected results. Note also that when used as a getter, this function is not chainable:\n     * it returns the axis, not the chart,\n     * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis\n     * so attempting to call chart functions after calling `.xAxis()` will fail}.\n     * @method xAxis\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3.axisBottom}\n     * @example\n     * // customize x axis tick format\n     * chart.xAxis().tickFormat(function(v) {return v + '%';});\n     * // customize x axis tick values\n     * chart.xAxis().tickValues([0, 100, 200, 300]);\n     * @param {d3.axis} [xAxis=d3.axisBottom()]\n     * @returns {d3.axis|dc.coordinateGridMixin}\n     */\n    _chart.xAxis = function (xAxis) {\n        if (!arguments.length) {\n            return _xAxis;\n        }\n        _xAxis = xAxis;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off elastic x axis behavior. If x axis elasticity is turned on, then the grid chart will\n     * attempt to recalculate the x axis range whenever a redraw event is triggered.\n     * @method elasticX\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [elasticX=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.elasticX = function (elasticX) {\n        if (!arguments.length) {\n            return _xElasticity;\n        }\n        _xElasticity = elasticX;\n        return _chart;\n    };\n\n    /**\n     * Set or get x axis padding for the elastic x axis. The padding will be added to both end of the x\n     * axis if elasticX is turned on; otherwise it is ignored.\n     *\n     * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to\n     * number or date x axes.  When padding a date axis, an integer represents number of units being padded\n     * and a percentage string will be treated the same as an integer. The unit will be determined by the\n     * xAxisPaddingUnit variable.\n     * @method xAxisPadding\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Number|String} [padding=0]\n     * @returns {Number|String|dc.coordinateGridMixin}\n     */\n    _chart.xAxisPadding = function (padding) {\n        if (!arguments.length) {\n            return _xAxisPadding;\n        }\n        _xAxisPadding = padding;\n        return _chart;\n    };\n\n    /**\n     * Set or get x axis padding unit for the elastic x axis. The padding unit will determine which unit to\n     * use when applying xAxis padding if elasticX is turned on and if x-axis uses a time dimension;\n     * otherwise it is ignored.\n     *\n     * The padding unit should be a\n     * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval).\n     * For backward compatibility with dc.js 2.0, it can also be the name of a d3 time interval\n     * ('day', 'hour', etc). Available arguments are the\n     * [d3 time intervals](https://github.com/d3/d3-time/blob/master/README.md#intervals d3.timeInterval).\n     * @method xAxisPaddingUnit\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {String} [unit=d3.timeDay]\n     * @returns {String|dc.coordinateGridMixin}\n     */\n    _chart.xAxisPaddingUnit = function (unit) {\n        if (!arguments.length) {\n            return _xAxisPaddingUnit;\n        }\n        _xAxisPaddingUnit = unit;\n        return _chart;\n    };\n\n    /**\n     * Returns the number of units displayed on the x axis. If the x axis is ordinal (`xUnits` is\n     * `dc.units.ordinal`), this is the number of items in the domain of the x scale. Otherwise, the\n     * x unit count is calculated using the {@link dc.coordinateGridMixin#xUnits xUnits} function.\n     * @method xUnitCount\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {Number}\n     */\n    _chart.xUnitCount = function () {\n        if (_unitCount === undefined) {\n            if (_chart.isOrdinal()) {\n                // In this case it number of items in domain\n                _unitCount = _chart.x().domain().length;\n            } else {\n                _unitCount = _chart.xUnits()(_chart.x().domain()[0], _chart.x().domain()[1]);\n\n                // Sometimes xUnits() may return an array while sometimes directly the count\n                if (_unitCount instanceof Array) {\n                    _unitCount = _unitCount.length;\n                }\n            }\n        }\n\n        return _unitCount;\n    };\n\n    /**\n     * Gets or sets whether the chart should be drawn with a right axis instead of a left axis. When\n     * used with a chart in a composite chart, allows both left and right Y axes to be shown on a\n     * chart.\n     * @method useRightYAxis\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [useRightYAxis=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.useRightYAxis = function (useRightYAxis) {\n        if (!arguments.length) {\n            return _useRightYAxis;\n        }\n\n        // We need to warn if value is changing after _yAxis was created\n        if (_useRightYAxis !== useRightYAxis && _yAxis) {\n            dc.logger.warn('Value of useRightYAxis has been altered, after yAxis was created. ' +\n                'You might get unexpected yAxis behavior. ' +\n                'Make calls to useRightYAxis sooner in your chart creation process.');\n        }\n\n        _useRightYAxis = useRightYAxis;\n        return _chart;\n    };\n\n    /**\n     * Returns true if the chart is using ordinal xUnits ({@link dc.units.ordinal dc.units.ordinal}, or false\n     * otherwise. Most charts behave differently with ordinal data and use the result of this method to\n     * trigger the appropriate logic.\n     * @method isOrdinal\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {Boolean}\n     */\n    _chart.isOrdinal = function () {\n        return _chart.xUnits() === dc.units.ordinal;\n    };\n\n    _chart._useOuterPadding = function () {\n        return true;\n    };\n\n    _chart._ordinalXDomain = function () {\n        var groups = _chart._computeOrderedGroups(_chart.data());\n        return groups.map(_chart.keyAccessor());\n    };\n\n    function prepareXAxis (g, render) {\n        if (!_chart.isOrdinal()) {\n            if (_chart.elasticX()) {\n                _x.domain([_chart.xAxisMin(), _chart.xAxisMax()]);\n            }\n        } else { // _chart.isOrdinal()\n            // D3v4 - Ordinal charts would need scaleBand\n            // bandwidth is a method in scaleBand\n            // (https://github.com/d3/d3-scale/blob/master/README.md#scaleBand)\n            if (!_x.bandwidth) {\n                // If _x is not a scaleBand create a new scale and\n                // copy the original domain to the new scale\n                dc.logger.warn('For compatibility with d3v4+, dc.js d3.0 ordinal bar/line/bubble charts need ' +\n                               'd3.scaleBand() for the x scale, instead of d3.scaleOrdinal(). ' +\n                               'Replacing .x() with a d3.scaleBand with the same domain - ' +\n                               'make the same change in your code to avoid this warning!');\n                _x = d3.scaleBand().domain(_x.domain());\n            }\n\n            if (_chart.elasticX() || _x.domain().length === 0) {\n                _x.domain(_chart._ordinalXDomain());\n            }\n        }\n\n        // has the domain changed?\n        var xdom = _x.domain();\n        if (render || !dc.utils.arraysEqual(_lastXDomain, xdom)) {\n            _chart.rescale();\n        }\n        _lastXDomain = xdom;\n\n        // please can't we always use rangeBands for bar charts?\n        if (_chart.isOrdinal()) {\n            _x.range([0, _chart.xAxisLength()])\n                .paddingInner(_rangeBandPadding)\n                .paddingOuter(_chart._useOuterPadding() ? _outerRangeBandPadding : 0);\n        } else {\n            _x.range([0, _chart.xAxisLength()]);\n        }\n\n        _xAxis = _xAxis.scale(_chart.x());\n\n        renderVerticalGridLines(g);\n    }\n\n    _chart.renderXAxis = function (g) {\n        var axisXG = g.select('g.x');\n\n        if (axisXG.empty()) {\n            axisXG = g.append('g')\n                .attr('class', 'axis x')\n                .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart._xAxisY() + ')');\n        }\n\n        var axisXLab = g.select('text.' + X_AXIS_LABEL_CLASS);\n        if (axisXLab.empty() && _chart.xAxisLabel()) {\n            axisXLab = g.append('text')\n                .attr('class', X_AXIS_LABEL_CLASS)\n                .attr('transform', 'translate(' + (_chart.margins().left + _chart.xAxisLength() / 2) + ',' +\n                      (_chart.height() - _xAxisLabelPadding) + ')')\n                .attr('text-anchor', 'middle');\n        }\n        if (_chart.xAxisLabel() && axisXLab.text() !== _chart.xAxisLabel()) {\n            axisXLab.text(_chart.xAxisLabel());\n        }\n\n        dc.transition(axisXG, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart._xAxisY() + ')')\n            .call(_xAxis);\n        dc.transition(axisXLab, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', 'translate(' + (_chart.margins().left + _chart.xAxisLength() / 2) + ',' +\n                  (_chart.height() - _xAxisLabelPadding) + ')');\n    };\n\n    function renderVerticalGridLines (g) {\n        var gridLineG = g.select('g.' + VERTICAL_CLASS);\n\n        if (_renderVerticalGridLine) {\n            if (gridLineG.empty()) {\n                gridLineG = g.insert('g', ':first-child')\n                    .attr('class', GRID_LINE_CLASS + ' ' + VERTICAL_CLASS)\n                    .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')');\n            }\n\n            var ticks = _xAxis.tickValues() ? _xAxis.tickValues() :\n                (typeof _x.ticks === 'function' ? _x.ticks.apply(_x, _xAxis.tickArguments()) : _x.domain());\n\n            var lines = gridLineG.selectAll('line')\n                .data(ticks);\n\n            // enter\n            var linesGEnter = lines.enter()\n                .append('line')\n                .attr('x1', function (d) {\n                    return _x(d);\n                })\n                .attr('y1', _chart._xAxisY() - _chart.margins().top)\n                .attr('x2', function (d) {\n                    return _x(d);\n                })\n                .attr('y2', 0)\n                .attr('opacity', 0);\n            dc.transition(linesGEnter, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('opacity', 1);\n\n            // update\n            var linesGEnterUpdate = linesGEnter.merge(lines);\n            dc.transition(linesGEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('x1', function (d) {\n                    return _x(d);\n                })\n                .attr('y1', _chart._xAxisY() - _chart.margins().top)\n                .attr('x2', function (d) {\n                    return _x(d);\n                })\n                .attr('y2', 0);\n\n            // exit\n            lines.exit().remove();\n        } else {\n            gridLineG.selectAll('line').remove();\n        }\n    }\n\n    _chart._xAxisY = function () {\n        return (_chart.height() - _chart.margins().bottom);\n    };\n\n    _chart.xAxisLength = function () {\n        return _chart.effectiveWidth();\n    };\n\n    /**\n     * Set or get the x axis label. If setting the label, you may optionally include additional padding to\n     * the margin to make room for the label. By default the padded is set to 12 to accomodate the text height.\n     * @method xAxisLabel\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {String} [labelText]\n     * @param {Number} [padding=12]\n     * @returns {String}\n     */\n    _chart.xAxisLabel = function (labelText, padding) {\n        if (!arguments.length) {\n            return _xAxisLabel;\n        }\n        _xAxisLabel = labelText;\n        _chart.margins().bottom -= _xAxisLabelPadding;\n        _xAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding;\n        _chart.margins().bottom += _xAxisLabelPadding;\n        return _chart;\n    };\n\n    function createYAxis () {\n        return _useRightYAxis ? d3.axisRight() : d3.axisLeft();\n    }\n\n    _chart._prepareYAxis = function (g) {\n        if (_y === undefined || _chart.elasticY()) {\n            if (_y === undefined) {\n                _y = d3.scaleLinear();\n            }\n            var min = _chart.yAxisMin() || 0,\n                max = _chart.yAxisMax() || 0;\n            _y.domain([min, max]).rangeRound([_chart.yAxisHeight(), 0]);\n        }\n\n        _y.range([_chart.yAxisHeight(), 0]);\n\n        if (!_yAxis) {\n            _yAxis = createYAxis();\n        }\n\n        _yAxis.scale(_y);\n\n        _chart._renderHorizontalGridLinesForAxis(g, _y, _yAxis);\n    };\n\n    _chart.renderYAxisLabel = function (axisClass, text, rotation, labelXPosition) {\n        labelXPosition = labelXPosition || _yAxisLabelPadding;\n\n        var axisYLab = _chart.g().select('text.' + Y_AXIS_LABEL_CLASS + '.' + axisClass + '-label');\n        var labelYPosition = (_chart.margins().top + _chart.yAxisHeight() / 2);\n        if (axisYLab.empty() && text) {\n            axisYLab = _chart.g().append('text')\n                .attr('transform', 'translate(' + labelXPosition + ',' + labelYPosition + '),rotate(' + rotation + ')')\n                .attr('class', Y_AXIS_LABEL_CLASS + ' ' + axisClass + '-label')\n                .attr('text-anchor', 'middle')\n                .text(text);\n        }\n        if (text && axisYLab.text() !== text) {\n            axisYLab.text(text);\n        }\n        dc.transition(axisYLab, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', 'translate(' + labelXPosition + ',' + labelYPosition + '),rotate(' + rotation + ')');\n    };\n\n    _chart.renderYAxisAt = function (axisClass, axis, position) {\n        var axisYG = _chart.g().select('g.' + axisClass);\n        if (axisYG.empty()) {\n            axisYG = _chart.g().append('g')\n                .attr('class', 'axis ' + axisClass)\n                .attr('transform', 'translate(' + position + ',' + _chart.margins().top + ')');\n        }\n\n        dc.transition(axisYG, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', 'translate(' + position + ',' + _chart.margins().top + ')')\n            .call(axis);\n    };\n\n    _chart.renderYAxis = function () {\n        var axisPosition = _useRightYAxis ? (_chart.width() - _chart.margins().right) : _chart._yAxisX();\n        _chart.renderYAxisAt('y', _yAxis, axisPosition);\n        var labelPosition = _useRightYAxis ? (_chart.width() - _yAxisLabelPadding) : _yAxisLabelPadding;\n        var rotation = _useRightYAxis ? 90 : -90;\n        _chart.renderYAxisLabel('y', _chart.yAxisLabel(), rotation, labelPosition);\n    };\n\n    _chart._renderHorizontalGridLinesForAxis = function (g, scale, axis) {\n        var gridLineG = g.select('g.' + HORIZONTAL_CLASS);\n\n        if (_renderHorizontalGridLine) {\n            // Last part copied from https://github.com/d3/d3-axis/blob/master/src/axis.js#L48\n            var ticks = axis.tickValues() ? axis.tickValues() : scale.ticks.apply(scale, axis.tickArguments());\n\n            if (gridLineG.empty()) {\n                gridLineG = g.insert('g', ':first-child')\n                    .attr('class', GRID_LINE_CLASS + ' ' + HORIZONTAL_CLASS)\n                    .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')');\n            }\n\n            var lines = gridLineG.selectAll('line')\n                .data(ticks);\n\n            // enter\n            var linesGEnter = lines.enter()\n                .append('line')\n                .attr('x1', 1)\n                .attr('y1', function (d) {\n                    return scale(d);\n                })\n                .attr('x2', _chart.xAxisLength())\n                .attr('y2', function (d) {\n                    return scale(d);\n                })\n                .attr('opacity', 0);\n            dc.transition(linesGEnter, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('opacity', 1);\n\n            // update\n            var linesGEnterUpdate = linesGEnter.merge(lines);\n            dc.transition(linesGEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('x1', 1)\n                .attr('y1', function (d) {\n                    return scale(d);\n                })\n                .attr('x2', _chart.xAxisLength())\n                .attr('y2', function (d) {\n                    return scale(d);\n                });\n\n            // exit\n            lines.exit().remove();\n        } else {\n            gridLineG.selectAll('line').remove();\n        }\n    };\n\n    _chart._yAxisX = function () {\n        return _chart.useRightYAxis() ? _chart.width() - _chart.margins().right : _chart.margins().left;\n    };\n\n    /**\n     * Set or get the y axis label. If setting the label, you may optionally include additional padding\n     * to the margin to make room for the label. By default the padding is set to 12 to accommodate the\n     * text height.\n     * @method yAxisLabel\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {String} [labelText]\n     * @param {Number} [padding=12]\n     * @returns {String|dc.coordinateGridMixin}\n     */\n    _chart.yAxisLabel = function (labelText, padding) {\n        if (!arguments.length) {\n            return _yAxisLabel;\n        }\n        _yAxisLabel = labelText;\n        _chart.margins().left -= _yAxisLabelPadding;\n        _yAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding;\n        _chart.margins().left += _yAxisLabelPadding;\n        return _chart;\n    };\n\n    /**\n     * Get or set the y scale. The y scale is typically automatically determined by the chart implementation.\n     * @method y\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @param {d3.scale} [yScale]\n     * @returns {d3.scale|dc.coordinateGridMixin}\n     */\n    _chart.y = function (yScale) {\n        if (!arguments.length) {\n            return _y;\n        }\n        _y = yScale;\n        _chart.rescale();\n        return _chart;\n    };\n\n    /**\n     * Set or get the y axis used by the coordinate grid chart instance. This function is most useful\n     * when y axis customization is required. Depending on `useRightYAxis` the y axis in dc.js is an instance of\n     * either [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft) or\n     * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight); therefore it supports any\n     * valid d3 axis manipulation.\n     *\n     * **Caution**: The y axis is usually generated internally by dc; resetting it may cause\n     * unexpected results.  Note also that when used as a getter, this function is not chainable: it\n     * returns the axis, not the chart,\n     * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis\n     * so attempting to call chart functions after calling `.yAxis()` will fail}.\n     * In addition, depending on whether you are going to use the axis on left or right\n     * you need to appropriately pass [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft)\n     * or [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight)\n     * @method yAxis\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-axis/blob/master/README.md d3.axis}\n     * @example\n     * // customize y axis tick format\n     * chart.yAxis().tickFormat(function(v) {return v + '%';});\n     * // customize y axis tick values\n     * chart.yAxis().tickValues([0, 100, 200, 300]);\n     * @param {d3.axisLeft|d3.axisRight} [yAxis]\n     * @returns {d3.axisLeft|d3.axisRight|dc.coordinateGridMixin}\n     */\n    _chart.yAxis = function (yAxis) {\n        if (!arguments.length) {\n            if (!_yAxis) {\n                _yAxis = createYAxis();\n            }\n            return _yAxis;\n        }\n        _yAxis = yAxis;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off elastic y axis behavior. If y axis elasticity is turned on, then the grid chart will\n     * attempt to recalculate the y axis range whenever a redraw event is triggered.\n     * @method elasticY\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [elasticY=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.elasticY = function (elasticY) {\n        if (!arguments.length) {\n            return _yElasticity;\n        }\n        _yElasticity = elasticY;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off horizontal grid lines.\n     * @method renderHorizontalGridLines\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [renderHorizontalGridLines=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.renderHorizontalGridLines = function (renderHorizontalGridLines) {\n        if (!arguments.length) {\n            return _renderHorizontalGridLine;\n        }\n        _renderHorizontalGridLine = renderHorizontalGridLines;\n        return _chart;\n    };\n\n    /**\n     * Turn on/off vertical grid lines.\n     * @method renderVerticalGridLines\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [renderVerticalGridLines=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.renderVerticalGridLines = function (renderVerticalGridLines) {\n        if (!arguments.length) {\n            return _renderVerticalGridLine;\n        }\n        _renderVerticalGridLine = renderVerticalGridLines;\n        return _chart;\n    };\n\n    /**\n     * Calculates the minimum x value to display in the chart. Includes xAxisPadding if set.\n     * @method xAxisMin\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {*}\n     */\n    _chart.xAxisMin = function () {\n        var min = d3.min(_chart.data(), function (e) {\n            return _chart.keyAccessor()(e);\n        });\n        return dc.utils.subtract(min, _xAxisPadding, _xAxisPaddingUnit);\n    };\n\n    /**\n     * Calculates the maximum x value to display in the chart. Includes xAxisPadding if set.\n     * @method xAxisMax\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {*}\n     */\n    _chart.xAxisMax = function () {\n        var max = d3.max(_chart.data(), function (e) {\n            return _chart.keyAccessor()(e);\n        });\n        return dc.utils.add(max, _xAxisPadding, _xAxisPaddingUnit);\n    };\n\n    /**\n     * Calculates the minimum y value to display in the chart. Includes yAxisPadding if set.\n     * @method yAxisMin\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {*}\n     */\n    _chart.yAxisMin = function () {\n        var min = d3.min(_chart.data(), function (e) {\n            return _chart.valueAccessor()(e);\n        });\n        return dc.utils.subtract(min, _yAxisPadding);\n    };\n\n    /**\n     * Calculates the maximum y value to display in the chart. Includes yAxisPadding if set.\n     * @method yAxisMax\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @returns {*}\n     */\n    _chart.yAxisMax = function () {\n        var max = d3.max(_chart.data(), function (e) {\n            return _chart.valueAccessor()(e);\n        });\n        return dc.utils.add(max, _yAxisPadding);\n    };\n\n    /**\n     * Set or get y axis padding for the elastic y axis. The padding will be added to the top and\n     * bottom of the y axis if elasticY is turned on; otherwise it is ignored.\n     *\n     * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to\n     * number or date axes. When padding a date axis, an integer represents number of days being padded\n     * and a percentage string will be treated the same as an integer.\n     * @method yAxisPadding\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Number|String} [padding=0]\n     * @returns {Number|dc.coordinateGridMixin}\n     */\n    _chart.yAxisPadding = function (padding) {\n        if (!arguments.length) {\n            return _yAxisPadding;\n        }\n        _yAxisPadding = padding;\n        return _chart;\n    };\n\n    _chart.yAxisHeight = function () {\n        return _chart.effectiveHeight();\n    };\n\n    /**\n     * Set or get the rounding function used to quantize the selection when brushing is enabled.\n     * @method round\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @example\n     * // set x unit round to by month, this will make sure range selection brush will\n     * // select whole months\n     * chart.round(d3.timeMonth.round);\n     * @param {Function} [round]\n     * @returns {Function|dc.coordinateGridMixin}\n     */\n    _chart.round = function (round) {\n        if (!arguments.length) {\n            return _round;\n        }\n        _round = round;\n        return _chart;\n    };\n\n    _chart._rangeBandPadding = function (_) {\n        if (!arguments.length) {\n            return _rangeBandPadding;\n        }\n        _rangeBandPadding = _;\n        return _chart;\n    };\n\n    _chart._outerRangeBandPadding = function (_) {\n        if (!arguments.length) {\n            return _outerRangeBandPadding;\n        }\n        _outerRangeBandPadding = _;\n        return _chart;\n    };\n\n    dc.override(_chart, 'filter', function (_) {\n        if (!arguments.length) {\n            return _chart._filter();\n        }\n\n        _chart._filter(_);\n\n        _chart.redrawBrush(_, false);\n\n        return _chart;\n    });\n\n    /**\n     * Get or set the brush. Brush must be an instance of d3 brushes\n     * https://github.com/d3/d3-brush/blob/master/README.md\n     * You will use this only if you are writing a new chart type that supports brushing.\n     *\n     * **Caution**: dc creates and manages brushes internally. Go through and understand the source code\n     * if you want to pass a new brush object. Even if you are only using the getter,\n     * the brush object may not behave the way you expect.\n     *\n     * @method brush\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {d3.brush} [_]\n     * @returns {d3.brush|dc.coordinateGridMixin}\n     */\n    _chart.brush = function (_) {\n        if (!arguments.length) {\n            return _brush;\n        }\n        _brush = _;\n        return _chart;\n    };\n\n    _chart.renderBrush = function (g, doTransition) {\n        if (_brushOn) {\n            _brush.on('start brush end', _chart._brushing);\n\n            // To retrieve selection we need _gBrush\n            _gBrush = g.append('g')\n                .attr('class', 'brush')\n                .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')');\n\n            _chart.setBrushExtents();\n\n            _chart.createBrushHandlePaths(_gBrush, doTransition);\n\n            _chart.redrawBrush(_chart.filter(), doTransition);\n        }\n    };\n\n    _chart.createBrushHandlePaths = function (gBrush) {\n        var brushHandles = gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS).data([{type: 'w'}, {type: 'e'}]);\n\n        brushHandles = brushHandles\n            .enter()\n            .append('path')\n            .attr('class', CUSTOM_BRUSH_HANDLE_CLASS)\n            .merge(brushHandles);\n\n        brushHandles\n            .attr('d', _chart.resizeHandlePath);\n    };\n\n    _chart.extendBrush = function (brushSelection) {\n        if (brushSelection && _chart.round()) {\n            brushSelection[0] = _chart.round()(brushSelection[0]);\n            brushSelection[1] = _chart.round()(brushSelection[1]);\n        }\n        return brushSelection;\n    };\n\n    _chart.brushIsEmpty = function (brushSelection) {\n        return !brushSelection || brushSelection[1] <= brushSelection[0];\n    };\n\n    _chart._brushing = function () {\n        // Avoids infinite recursion (mutual recursion between range and focus operations)\n        // Source Event will be null when brush.move is called programmatically (see below as well).\n        if (!d3.event.sourceEvent) { return; }\n\n        // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.)\n        // In this case we are more worried about this handler causing brush move programmatically which will\n        // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent)\n        // This check avoids recursive calls\n        if (d3.event.sourceEvent.type && ['start', 'brush', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) {\n            return;\n        }\n\n        var brushSelection = d3.event.selection;\n        if (brushSelection) {\n            brushSelection = brushSelection.map(_chart.x().invert);\n        }\n\n        brushSelection = _chart.extendBrush(brushSelection);\n\n        _chart.redrawBrush(brushSelection, false);\n\n        var rangedFilter = _chart.brushIsEmpty(brushSelection) ? null : dc.filters.RangedFilter(brushSelection[0], brushSelection[1]);\n\n        dc.events.trigger(function () {\n            _chart.applyBrushSelection(rangedFilter);\n        }, dc.constants.EVENT_DELAY);\n    };\n\n    // This can be overridden in a derived chart. For example Composite chart overrides it\n    _chart.applyBrushSelection = function (rangedFilter) {\n        _chart.replaceFilter(rangedFilter);\n        _chart.redrawGroup();\n    };\n\n    _chart.setBrushExtents = function (doTransition) {\n        // Set boundaries of the brush, must set it before applying to _gBrush\n        _brush.extent([[0, 0], [_chart.effectiveWidth(), _chart.effectiveHeight()]]);\n\n        _gBrush\n            .call(_brush);\n    };\n\n    _chart.redrawBrush = function (brushSelection, doTransition) {\n        if (_brushOn && _gBrush) {\n            if (_resizing) {\n                _chart.setBrushExtents(doTransition);\n            }\n\n            if (!brushSelection) {\n                _gBrush\n                    .call(_brush.move, null);\n\n                _gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS)\n                    .attr('display', 'none');\n            } else {\n                var scaledSelection = [_x(brushSelection[0]), _x(brushSelection[1])];\n\n                var gBrush =\n                    dc.optionalTransition(doTransition, _chart.transitionDuration(), _chart.transitionDelay())(_gBrush);\n\n                gBrush\n                    .call(_brush.move, scaledSelection);\n\n                gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS)\n                    .attr('display', null)\n                    .attr('transform', function (d, i) {\n                        return 'translate(' + _x(brushSelection[i]) + ', 0)';\n                    })\n                    .attr('d', _chart.resizeHandlePath);\n            }\n        }\n        _chart.fadeDeselectedArea(brushSelection);\n    };\n\n    _chart.fadeDeselectedArea = function (brushSelection) {\n        // do nothing, sub-chart should override this function\n    };\n\n    // borrowed from Crossfilter example\n    _chart.resizeHandlePath = function (d) {\n        d = d.type;\n        var e = +(d === 'e'), x = e ? 1 : -1, y = _chart.effectiveHeight() / 3;\n        return 'M' + (0.5 * x) + ',' + y +\n            'A6,6 0 0 ' + e + ' ' + (6.5 * x) + ',' + (y + 6) +\n            'V' + (2 * y - 6) +\n            'A6,6 0 0 ' + e + ' ' + (0.5 * x) + ',' + (2 * y) +\n            'Z' +\n            'M' + (2.5 * x) + ',' + (y + 8) +\n            'V' + (2 * y - 8) +\n            'M' + (4.5 * x) + ',' + (y + 8) +\n            'V' + (2 * y - 8);\n    };\n\n    function getClipPathId () {\n        return _chart.anchorName().replace(/[ .#=\\[\\]\"]/g, '-') + '-clip';\n    }\n\n    /**\n     * Get or set the padding in pixels for the clip path. Once set padding will be applied evenly to\n     * the top, left, right, and bottom when the clip path is generated. If set to zero, the clip area\n     * will be exactly the chart body area minus the margins.\n     * @method clipPadding\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Number} [padding=5]\n     * @returns {Number|dc.coordinateGridMixin}\n     */\n    _chart.clipPadding = function (padding) {\n        if (!arguments.length) {\n            return _clipPadding;\n        }\n        _clipPadding = padding;\n        return _chart;\n    };\n\n    function generateClipPath () {\n        var defs = dc.utils.appendOrSelect(_parent, 'defs');\n        // cannot select <clippath> elements; bug in WebKit, must select by id\n        // https://groups.google.com/forum/#!topic/d3-js/6EpAzQ2gU9I\n        var id = getClipPathId();\n        var chartBodyClip = dc.utils.appendOrSelect(defs, '#' + id, 'clipPath').attr('id', id);\n\n        var padding = _clipPadding * 2;\n\n        dc.utils.appendOrSelect(chartBodyClip, 'rect')\n            .attr('width', _chart.xAxisLength() + padding)\n            .attr('height', _chart.yAxisHeight() + padding)\n            .attr('transform', 'translate(-' + _clipPadding + ', -' + _clipPadding + ')');\n    }\n\n    _chart._preprocessData = function () {};\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n\n        _chart._preprocessData();\n\n        _chart._generateG();\n        generateClipPath();\n\n        drawChart(true);\n\n        configureMouseZoom();\n\n        return _chart;\n    };\n\n    _chart._doRedraw = function () {\n        _chart._preprocessData();\n\n        drawChart(false);\n        generateClipPath();\n\n        return _chart;\n    };\n\n    function drawChart (render) {\n        if (_chart.isOrdinal()) {\n            _brushOn = false;\n        }\n\n        prepareXAxis(_chart.g(), render);\n        _chart._prepareYAxis(_chart.g());\n\n        _chart.plotData();\n\n        if (_chart.elasticX() || _resizing || render) {\n            _chart.renderXAxis(_chart.g());\n        }\n\n        if (_chart.elasticY() || _resizing || render) {\n            _chart.renderYAxis(_chart.g());\n        }\n\n        if (render) {\n            _chart.renderBrush(_chart.g(), false);\n        } else {\n            // Animate the brush only while resizing\n            _chart.redrawBrush(_chart.filter(), _resizing);\n        }\n        _chart.fadeDeselectedArea(_chart.filter());\n        _resizing = false;\n    }\n\n    function configureMouseZoom () {\n        // Save a copy of original x scale\n        _origX = _x.copy();\n\n        if (_mouseZoomable) {\n            _chart._enableMouseZoom();\n        } else if (_hasBeenMouseZoomable) {\n            _chart._disableMouseZoom();\n        }\n    }\n\n    _chart._enableMouseZoom = function () {\n        _hasBeenMouseZoomable = true;\n\n        var extent = [[0, 0],[_chart.effectiveWidth(), _chart.effectiveHeight()]];\n\n        _zoom\n            .scaleExtent(_zoomScale)\n            .extent(extent)\n            .duration(_chart.transitionDuration());\n\n        if (_zoomOutRestrict) {\n            // Ensure minimum zoomScale is at least 1\n            var zoomScaleMin = Math.max(_zoomScale[0], 1);\n            _zoom\n                .translateExtent(extent)\n                .scaleExtent([zoomScaleMin, _zoomScale[1]]);\n        }\n\n        _chart.root().call(_zoom);\n\n        // Tell D3 zoom our current zoom/pan status\n        updateD3zoomTransform();\n    };\n\n    _chart._disableMouseZoom = function () {\n        _chart.root().call(_nullZoom);\n    };\n\n    function zoomHandler (newDomain, noRaiseEvents) {\n        var domFilter;\n\n        if (hasRangeSelected(newDomain)) {\n            _chart.x().domain(newDomain);\n            domFilter = dc.filters.RangedFilter(newDomain[0], newDomain[1]);\n        } else {\n            _chart.x().domain(_xOriginalDomain);\n            domFilter = null;\n        }\n\n        _chart.replaceFilter(domFilter);\n        _chart.rescale();\n        _chart.redraw();\n\n        if (!noRaiseEvents) {\n            if (_rangeChart && !rangesEqual(_chart.filter(), _rangeChart.filter())) {\n                dc.events.trigger(function () {\n                    _rangeChart.replaceFilter(domFilter);\n                    _rangeChart.redraw();\n                });\n            }\n\n            _chart._invokeZoomedListener();\n            dc.events.trigger(function () {\n                _chart.redrawGroup();\n            }, dc.constants.EVENT_DELAY);\n        }\n    }\n\n    // event.transform.rescaleX(_origX).domain() should give back newDomain\n    function domainToZoomTransform (newDomain, origDomain, xScale) {\n        var k = (origDomain[1] - origDomain[0]) / (newDomain[1] - newDomain[0]);\n        var xt = -1 * xScale(newDomain[0]);\n\n        return d3.zoomIdentity.scale(k).translate(xt, 0);\n    }\n\n    // If we changing zoom status (for example by calling focus), tell D3 zoom about it\n    function updateD3zoomTransform () {\n        if (_zoom) {\n            _zoom.transform(_chart.root(), domainToZoomTransform(_chart.x().domain(), _xOriginalDomain, _origX));\n        }\n    }\n\n    function onZoom () {\n        // Avoids infinite recursion (mutual recursion between range and focus operations)\n        // Source Event will be null when zoom is called programmatically (see below as well).\n        if (!d3.event.sourceEvent) { return; }\n\n        // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.)\n        // In this case we are more worried about this handler causing zoom programmatically which will\n        // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent)\n        // This check avoids recursive calls\n        if (d3.event.sourceEvent.type && ['start', 'zoom', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) {\n            return;\n        }\n\n        var newDomain = d3.event.transform.rescaleX(_origX).domain();\n        _chart.focus(newDomain, false);\n    }\n\n    function checkExtents (ext, outerLimits) {\n        if (!ext || ext.length !== 2 || !outerLimits || outerLimits.length !== 2) {\n            return ext;\n        }\n\n        if (ext[0] > outerLimits[1] || ext[1] < outerLimits[0]) {\n            console.warn('Could not intersect extents, will reset');\n        }\n        // Math.max does not work (as the values may be dates as well)\n        return [ext[0] > outerLimits[0] ? ext[0] : outerLimits[0], ext[1] < outerLimits[1] ? ext[1] : outerLimits[1]];\n    }\n\n    /**\n     * Zoom this chart to focus on the given range. The given range should be an array containing only\n     * 2 elements (`[start, end]`) defining a range in the x domain. If the range is not given or set\n     * to null, then the zoom will be reset. _For focus to work elasticX has to be turned off;\n     * otherwise focus will be ignored.\n     *\n     * To avoid ping-pong volley of events between a pair of range and focus charts please set\n     * `noRaiseEvents` to `true`. In that case it will update this chart but will not fire `zoom` event\n     * and not try to update back the associated range chart.\n     * If you are calling it manually - typically you will leave it to `false` (the default).\n     *\n     * @method focus\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @example\n     * chart.on('renderlet', function(chart) {\n     *     // smooth the rendering through event throttling\n     *     dc.events.trigger(function(){\n     *          // focus some other chart to the range selected by user on this chart\n     *          someOtherChart.focus(chart.filter());\n     *     });\n     * })\n     * @param {Array<Number>} [range]\n     * @param {Boolean} [noRaiseEvents = false]\n     */\n    _chart.focus = function (range, noRaiseEvents) {\n        if (_zoomOutRestrict) {\n            // ensure range is within _xOriginalDomain\n            range = checkExtents(range, _xOriginalDomain);\n\n            // If it has an associated range chart ensure range is within domain of that rangeChart\n            if (_rangeChart) {\n                range = checkExtents(range, _rangeChart.x().domain());\n            }\n        }\n\n        zoomHandler(range, noRaiseEvents);\n        updateD3zoomTransform();\n    };\n\n    _chart.refocused = function () {\n        return !rangesEqual(_chart.x().domain(), _xOriginalDomain);\n    };\n\n    _chart.focusChart = function (c) {\n        if (!arguments.length) {\n            return _focusChart;\n        }\n        _focusChart = c;\n        _chart.on('filtered', function (chart) {\n            if (!chart.filter()) {\n                dc.events.trigger(function () {\n                    _focusChart.x().domain(_focusChart.xOriginalDomain(), true);\n                });\n            } else if (!rangesEqual(chart.filter(), _focusChart.filter())) {\n                dc.events.trigger(function () {\n                    _focusChart.focus(chart.filter(), true);\n                });\n            }\n        });\n        return _chart;\n    };\n\n    function rangesEqual (range1, range2) {\n        if (!range1 && !range2) {\n            return true;\n        } else if (!range1 || !range2) {\n            return false;\n        } else if (range1.length === 0 && range2.length === 0) {\n            return true;\n        } else if (range1[0].valueOf() === range2[0].valueOf() &&\n            range1[1].valueOf() === range2[1].valueOf()) {\n            return true;\n        }\n        return false;\n    }\n\n    /**\n     * Turn on/off the brush-based range filter. When brushing is on then user can drag the mouse\n     * across a chart with a quantitative scale to perform range filtering based on the extent of the\n     * brush, or click on the bars of an ordinal bar chart or slices of a pie chart to filter and\n     * un-filter them. However turning on the brush filter will disable other interactive elements on\n     * the chart such as highlighting, tool tips, and reference lines. Zooming will still be possible\n     * if enabled, but only via scrolling (panning will be disabled.)\n     * @method brushOn\n     * @memberof dc.coordinateGridMixin\n     * @instance\n     * @param {Boolean} [brushOn=true]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.brushOn = function (brushOn) {\n        if (!arguments.length) {\n            return _brushOn;\n        }\n        _brushOn = brushOn;\n        return _chart;\n    };\n\n    /**\n     * This will be internally used by composite chart onto children. Please go not invoke directly.\n     *\n     * @method parentBrushOn\n     * @memberof dc.coordinateGridMixin\n     * @protected\n     * @instance\n     * @param {Boolean} [brushOn=false]\n     * @returns {Boolean|dc.coordinateGridMixin}\n     */\n    _chart.parentBrushOn = function (brushOn) {\n        if (!arguments.length) {\n            return _parentBrushOn;\n        }\n        _parentBrushOn = brushOn;\n        return _chart;\n    };\n\n    // Get the SVG rendered brush\n    _chart.gBrush = function () {\n        return _gBrush;\n    };\n\n    function hasRangeSelected (range) {\n        return range instanceof Array && range.length > 1;\n    }\n\n    return _chart;\n};\n\n/**\n * Stack Mixin is an mixin that provides cross-chart support of stackability using d3.stackD3v3.\n * @name stackMixin\n * @memberof dc\n * @mixin\n * @param {Object} _chart\n * @returns {dc.stackMixin}\n */\ndc.stackMixin = function (_chart) {\n\n    function prepareValues (layer, layerIdx) {\n        var valAccessor = layer.accessor || _chart.valueAccessor();\n        layer.name = String(layer.name || layerIdx);\n        var allValues = layer.group.all().map(function (d, i) {\n            return {\n                x: _chart.keyAccessor()(d, i),\n                y: layer.hidden ? null : valAccessor(d, i),\n                data: d,\n                layer: layer.name,\n                hidden: layer.hidden\n            };\n        });\n\n        layer.domainValues = allValues.filter(domainFilter());\n        layer.values = _chart.evadeDomainFilter() ? allValues : layer.domainValues;\n    }\n\n    var _stackLayout = d3.stack();\n\n    var _stack = [];\n    var _titles = {};\n\n    var _hidableStacks = false;\n    var _evadeDomainFilter = false;\n\n    function domainFilter () {\n        if (!_chart.x()) {\n            return dc.utils.constant(true);\n        }\n        var xDomain = _chart.x().domain();\n        if (_chart.isOrdinal()) {\n            // TODO #416\n            //var domainSet = d3.set(xDomain);\n            return function () {\n                return true; //domainSet.has(p.x);\n            };\n        }\n        if (_chart.elasticX()) {\n            return function () { return true; };\n        }\n        return function (p) {\n            //return true;\n            return p.x >= xDomain[0] && p.x <= xDomain[xDomain.length - 1];\n        };\n    }\n\n    /**\n     * Stack a new crossfilter group onto this chart with an optional custom value accessor. All stacks\n     * in the same chart will share the same key accessor and therefore the same set of keys.\n     *\n     * For example, in a stacked bar chart, the bars of each stack will be positioned using the same set\n     * of keys on the x axis, while stacked vertically. If name is specified then it will be used to\n     * generate the legend label.\n     * @method stack\n     * @memberof dc.stackMixin\n     * @instance\n     * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter.group}\n     * @example\n     * // stack group using default accessor\n     * chart.stack(valueSumGroup)\n     * // stack group using custom accessor\n     * .stack(avgByDayGroup, function(d){return d.value.avgByDay;});\n     * @param {crossfilter.group} group\n     * @param {String} [name]\n     * @param {Function} [accessor]\n     * @returns {Array<{group: crossfilter.group, name: String, accessor: Function}>|dc.stackMixin}\n     */\n    _chart.stack = function (group, name, accessor) {\n        if (!arguments.length) {\n            return _stack;\n        }\n\n        if (arguments.length <= 2) {\n            accessor = name;\n        }\n\n        var layer = {group: group};\n        if (typeof name === 'string') {\n            layer.name = name;\n        }\n        if (typeof accessor === 'function') {\n            layer.accessor = accessor;\n        }\n        _stack.push(layer);\n\n        return _chart;\n    };\n\n    dc.override(_chart, 'group', function (g, n, f) {\n        if (!arguments.length) {\n            return _chart._group();\n        }\n        _stack = [];\n        _titles = {};\n        _chart.stack(g, n);\n        if (f) {\n            _chart.valueAccessor(f);\n        }\n        return _chart._group(g, n);\n    });\n\n    /**\n     * Allow named stacks to be hidden or shown by clicking on legend items.\n     * This does not affect the behavior of hideStack or showStack.\n     * @method hidableStacks\n     * @memberof dc.stackMixin\n     * @instance\n     * @param {Boolean} [hidableStacks=false]\n     * @returns {Boolean|dc.stackMixin}\n     */\n    _chart.hidableStacks = function (hidableStacks) {\n        if (!arguments.length) {\n            return _hidableStacks;\n        }\n        _hidableStacks = hidableStacks;\n        return _chart;\n    };\n\n    function findLayerByName (n) {\n        var i = _stack.map(dc.pluck('name')).indexOf(n);\n        return _stack[i];\n    }\n\n    /**\n     * Hide all stacks on the chart with the given name.\n     * The chart must be re-rendered for this change to appear.\n     * @method hideStack\n     * @memberof dc.stackMixin\n     * @instance\n     * @param {String} stackName\n     * @returns {dc.stackMixin}\n     */\n    _chart.hideStack = function (stackName) {\n        var layer = findLayerByName(stackName);\n        if (layer) {\n            layer.hidden = true;\n        }\n        return _chart;\n    };\n\n    /**\n     * Show all stacks on the chart with the given name.\n     * The chart must be re-rendered for this change to appear.\n     * @method showStack\n     * @memberof dc.stackMixin\n     * @instance\n     * @param {String} stackName\n     * @returns {dc.stackMixin}\n     */\n    _chart.showStack = function (stackName) {\n        var layer = findLayerByName(stackName);\n        if (layer) {\n            layer.hidden = false;\n        }\n        return _chart;\n    };\n\n    _chart.getValueAccessorByIndex = function (index) {\n        return _stack[index].accessor || _chart.valueAccessor();\n    };\n\n    _chart.yAxisMin = function () {\n        var min = d3.min(flattenStack(), function (p) {\n            return (p.y < 0) ? (p.y + p.y0) : p.y0;\n        });\n\n        return dc.utils.subtract(min, _chart.yAxisPadding());\n\n    };\n\n    _chart.yAxisMax = function () {\n        var max = d3.max(flattenStack(), function (p) {\n            return (p.y > 0) ? (p.y + p.y0) : p.y0;\n        });\n\n        return dc.utils.add(max, _chart.yAxisPadding());\n    };\n\n    function flattenStack () {\n        var valueses = _chart.data().map(function (layer) { return layer.domainValues; });\n        return Array.prototype.concat.apply([], valueses);\n    }\n\n    _chart.xAxisMin = function () {\n        var min = d3.min(flattenStack(), dc.pluck('x'));\n        return dc.utils.subtract(min, _chart.xAxisPadding(), _chart.xAxisPaddingUnit());\n    };\n\n    _chart.xAxisMax = function () {\n        var max = d3.max(flattenStack(), dc.pluck('x'));\n        return dc.utils.add(max, _chart.xAxisPadding(), _chart.xAxisPaddingUnit());\n    };\n\n    /**\n     * Set or get the title function. Chart class will use this function to render svg title (usually interpreted by\n     * browser as tooltips) for each child element in the chart, i.e. a slice in a pie chart or a bubble in a bubble chart.\n     * Almost every chart supports title function however in grid coordinate chart you need to turn off brush in order to\n     * use title otherwise the brush layer will block tooltip trigger.\n     *\n     * If the first argument is a stack name, the title function will get or set the title for that stack. If stackName\n     * is not provided, the first stack is implied.\n     * @method title\n     * @memberof dc.stackMixin\n     * @instance\n     * @example\n     * // set a title function on 'first stack'\n     * chart.title('first stack', function(d) { return d.key + ': ' + d.value; });\n     * // get a title function from 'second stack'\n     * var secondTitleFunction = chart.title('second stack');\n     * @param {String} [stackName]\n     * @param {Function} [titleAccessor]\n     * @returns {String|dc.stackMixin}\n     */\n    dc.override(_chart, 'title', function (stackName, titleAccessor) {\n        if (!stackName) {\n            return _chart._title();\n        }\n\n        if (typeof stackName === 'function') {\n            return _chart._title(stackName);\n        }\n        if (stackName === _chart._groupName && typeof titleAccessor === 'function') {\n            return _chart._title(titleAccessor);\n        }\n\n        if (typeof titleAccessor !== 'function') {\n            return _titles[stackName] || _chart._title();\n        }\n\n        _titles[stackName] = titleAccessor;\n\n        return _chart;\n    });\n\n    /**\n     * Gets or sets the stack layout algorithm, which computes a baseline for each stack and\n     * propagates it to the next.\n     * @method stackLayout\n     * @memberof dc.stackMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-3.x-api-reference/blob/master/Stack-Layout.md d3.stackD3v3}\n     * @param {Function} [stack=d3.stackD3v3]\n     * @returns {Function|dc.stackMixin}\n     */\n    _chart.stackLayout = function (stack) {\n        if (!arguments.length) {\n            return _stackLayout;\n        }\n        _stackLayout = stack;\n        return _chart;\n    };\n\n    /**\n     * Since dc.js 2.0, there has been {@link https://github.com/dc-js/dc.js/issues/949 an issue}\n     * where points are filtered to the current domain. While this is a useful optimization, it is\n     * incorrectly implemented: the next point outside the domain is required in order to draw lines\n     * that are clipped to the bounds, as well as bars that are partly clipped.\n     *\n     * A fix will be included in dc.js 2.1.x, but a workaround is needed for dc.js 2.0 and until\n     * that fix is published, so set this flag to skip any filtering of points.\n     *\n     * Once the bug is fixed, this flag will have no effect, and it will be deprecated.\n     * @method evadeDomainFilter\n     * @memberof dc.stackMixin\n     * @instance\n     * @param {Boolean} [evadeDomainFilter=false]\n     * @returns {Boolean|dc.stackMixin}\n     */\n    _chart.evadeDomainFilter = function (evadeDomainFilter) {\n        if (!arguments.length) {\n            return _evadeDomainFilter;\n        }\n        _evadeDomainFilter = evadeDomainFilter;\n        return _chart;\n    };\n\n    function visibility (l) {\n        return !l.hidden;\n    }\n\n    _chart.data(function () {\n        var layers = _stack.filter(visibility);\n        if (!layers.length) {\n            return [];\n        }\n        layers.forEach(prepareValues);\n        var v4data = layers[0].values.map(function (v, i) {\n            var col = {x: v.x};\n            layers.forEach(function (layer) {\n                col[layer.name] = layer.values[i].y;\n            });\n            return col;\n        });\n        var keys = layers.map(function (layer) { return layer.name; });\n        var v4result = _chart.stackLayout().keys(keys)(v4data);\n        v4result.forEach(function (series, i) {\n            series.forEach(function (ys, j) {\n                layers[i].values[j].y0 = ys[0];\n                layers[i].values[j].y1 = ys[1];\n            });\n        });\n        return layers;\n    });\n\n    _chart._ordinalXDomain = function () {\n        var flat = flattenStack().map(dc.pluck('data'));\n        var ordered = _chart._computeOrderedGroups(flat);\n        return ordered.map(_chart.keyAccessor());\n    };\n\n    _chart.colorAccessor(function (d) {\n        var layer = this.layer || this.name || d.name || d.layer;\n        return layer;\n    });\n\n    _chart.legendables = function () {\n        return _stack.map(function (layer, i) {\n            return {\n                chart: _chart,\n                name: layer.name,\n                hidden: layer.hidden || false,\n                color: _chart.getColor.call(layer, layer.values, i)\n            };\n        });\n    };\n\n    _chart.isLegendableHidden = function (d) {\n        var layer = findLayerByName(d.name);\n        return layer ? layer.hidden : false;\n    };\n\n    _chart.legendToggle = function (d) {\n        if (_hidableStacks) {\n            if (_chart.isLegendableHidden(d)) {\n                _chart.showStack(d.name);\n            } else {\n                _chart.hideStack(d.name);\n            }\n            //_chart.redraw();\n            _chart.renderGroup();\n        }\n    };\n\n    return _chart;\n};\n\n/**\n * Cap is a mixin that groups small data elements below a _cap_ into an *others* grouping for both the\n * Row and Pie Charts.\n *\n * The top ordered elements in the group up to the cap amount will be kept in the chart, and the rest\n * will be replaced with an *others* element, with value equal to the sum of the replaced values. The\n * keys of the elements below the cap limit are recorded in order to filter by those keys when the\n * others* element is clicked.\n * @name capMixin\n * @memberof dc\n * @mixin\n * @param {Object} _chart\n * @returns {dc.capMixin}\n */\ndc.capMixin = function (_chart) {\n    var _cap = Infinity, _takeFront = true;\n    var _othersLabel = 'Others';\n\n    // emulate old group.top(N) ordering\n    _chart.ordering(function (kv) {\n        return -kv.value;\n    });\n\n    var _othersGrouper = function (topItems, restItems) {\n        var restItemsSum = d3.sum(restItems, _chart.valueAccessor()),\n            restKeys = restItems.map(_chart.keyAccessor());\n        if (restItemsSum > 0) {\n            return topItems.concat([{\n                others: restKeys,\n                key: _chart.othersLabel(),\n                value: restItemsSum\n            }]);\n        }\n        return topItems;\n    };\n\n    _chart.cappedKeyAccessor = function (d, i) {\n        if (d.others) {\n            return d.key;\n        }\n        return _chart.keyAccessor()(d, i);\n    };\n\n    _chart.cappedValueAccessor = function (d, i) {\n        if (d.others) {\n            return d.value;\n        }\n        return _chart.valueAccessor()(d, i);\n    };\n\n    // return N \"top\" groups, where N is the cap, sorted by baseMixin.ordering\n    // whether top means front or back depends on takeFront\n    _chart.data(function (group) {\n        if (_cap === Infinity) {\n            return _chart._computeOrderedGroups(group.all());\n        } else {\n            var items = group.all(), rest;\n            items = _chart._computeOrderedGroups(items); // sort by baseMixin.ordering\n\n            if (_cap) {\n                if (_takeFront) {\n                    rest = items.slice(_cap);\n                    items = items.slice(0, _cap);\n                } else {\n                    var start = Math.max(0, items.length - _cap);\n                    rest = items.slice(0, start);\n                    items = items.slice(start);\n                }\n            }\n\n            if (_othersGrouper) {\n                return _othersGrouper(items, rest);\n            }\n            return items;\n        }\n    });\n\n    /**\n     * Get or set the count of elements to that will be included in the cap. If there is an\n     * {@link dc.capMixin#othersGrouper othersGrouper}, any further elements will be combined in an\n     * extra element with its name determined by {@link dc.capMixin#othersLabel othersLabel}.\n     *\n     * As of dc.js 2.1 and onward, the capped charts use\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_all group.all()}\n     * and {@link dc.baseMixin#ordering baseMixin.ordering()} to determine the order of\n     * elements. Then `cap` and {@link dc.capMixin#takeFront takeFront} determine how many elements\n     * to keep, from which end of the resulting array.\n     *\n     * **Migration note:** Up through dc.js 2.0.*, capping used\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_top group.top(N)},\n     * which selects the largest items according to\n     * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_order group.order()}.\n     * The chart then sorted the items according to {@link dc.baseMixin#ordering baseMixin.ordering()}.\n     * So the two values essentially had to agree, but if the `group.order()` was incorrect (it's\n     * easy to forget about), the wrong rows or slices would be displayed, in the correct order.\n     *\n     * If your chart previously relied on `group.order()`, use `chart.ordering()` instead. As of\n     * 2.1.5, the ordering defaults to sorting from greatest to least like `group.top(N)` did.\n     *\n     * If you want to cap by one ordering but sort by another, please\n     * [file an issue](https://github.com/dc-js/dc.js/issues/new) - it's still possible but we'll\n     * need to work up an example.\n     * @method cap\n     * @memberof dc.capMixin\n     * @instance\n     * @param {Number} [count=Infinity]\n     * @returns {Number|dc.capMixin}\n     */\n    _chart.cap = function (count) {\n        if (!arguments.length) {\n            return _cap;\n        }\n        _cap = count;\n        return _chart;\n    };\n\n    /**\n     * Get or set the direction of capping. If set, the chart takes the first\n     * {@link dc.capMixin#cap cap} elements from the sorted array of elements; otherwise\n     * it takes the last `cap` elements.\n     * @method takeFront\n     * @memberof dc.capMixin\n     * @instance\n     * @param {Boolean} [takeFront=true]\n     * @returns {Boolean|dc.capMixin}\n     */\n    _chart.takeFront = function (takeFront) {\n        if (!arguments.length) {\n            return _takeFront;\n        }\n        _takeFront = takeFront;\n        return _chart;\n    };\n\n    /**\n     * Get or set the label for *Others* slice when slices cap is specified.\n     * @method othersLabel\n     * @memberof dc.capMixin\n     * @instance\n     * @param {String} [label=\"Others\"]\n     * @returns {String|dc.capMixin}\n     */\n    _chart.othersLabel = function (label) {\n        if (!arguments.length) {\n            return _othersLabel;\n        }\n        _othersLabel = label;\n        return _chart;\n    };\n\n    /**\n     * Get or set the grouper function that will perform the insertion of data for the *Others* slice\n     * if the slices cap is specified. If set to a falsy value, no others will be added.\n     *\n     * The grouper function takes an array of included (\"top\") items, and an array of the rest of\n     * the items. By default the grouper function computes the sum of the rest.\n     * @method othersGrouper\n     * @memberof dc.capMixin\n     * @instance\n     * @example\n     * // Do not show others\n     * chart.othersGrouper(null);\n     * // Default others grouper\n     * chart.othersGrouper(function (topItems, restItems) {\n     *     var restItemsSum = d3.sum(restItems, _chart.valueAccessor()),\n     *         restKeys = restItems.map(_chart.keyAccessor());\n     *     if (restItemsSum > 0) {\n     *         return topItems.concat([{\n     *             others: restKeys,\n     *             key: _chart.othersLabel(),\n     *             value: restItemsSum\n     *         }]);\n     *     }\n     *     return topItems;\n     * });\n     * @param {Function} [grouperFunction]\n     * @returns {Function|dc.capMixin}\n     */\n    _chart.othersGrouper = function (grouperFunction) {\n        if (!arguments.length) {\n            return _othersGrouper;\n        }\n        _othersGrouper = grouperFunction;\n        return _chart;\n    };\n\n    dc.override(_chart, 'onClick', function (d) {\n        if (d.others) {\n            _chart.filter([d.others]);\n        }\n        _chart._onClick(d);\n    });\n\n    return _chart;\n};\n\n/**\n * This Mixin provides reusable functionalities for any chart that needs to visualize data using bubbles.\n * @name bubbleMixin\n * @memberof dc\n * @mixin\n * @mixes dc.colorMixin\n * @param {Object} _chart\n * @returns {dc.bubbleMixin}\n */\ndc.bubbleMixin = function (_chart) {\n    var _maxBubbleRelativeSize = 0.3;\n    var _minRadiusWithLabel = 10;\n    var _sortBubbleSize = false;\n    var _elasticRadius = false;\n\n    _chart.BUBBLE_NODE_CLASS = 'node';\n    _chart.BUBBLE_CLASS = 'bubble';\n    _chart.MIN_RADIUS = 10;\n\n    _chart = dc.colorMixin(_chart);\n\n    _chart.renderLabel(true);\n\n    _chart.data(function (group) {\n        var data = group.all();\n        if (_sortBubbleSize) {\n            // sort descending so smaller bubbles are on top\n            var radiusAccessor = _chart.radiusValueAccessor();\n            data.sort(function (a, b) { return d3.descending(radiusAccessor(a), radiusAccessor(b)); });\n        }\n        return data;\n    });\n\n    var _r = d3.scaleLinear().domain([0, 100]);\n\n    var _rValueAccessor = function (d) {\n        return d.r;\n    };\n\n    /**\n     * Get or set the bubble radius scale. By default the bubble chart uses\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleLinear d3.scaleLinear().domain([0, 100])}\n     * as its radius scale.\n     * @method r\n     * @memberof dc.bubbleMixin\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @param {d3.scale} [bubbleRadiusScale=d3.scaleLinear().domain([0, 100])]\n     * @returns {d3.scale|dc.bubbleMixin}\n     */\n    _chart.r = function (bubbleRadiusScale) {\n        if (!arguments.length) {\n            return _r;\n        }\n        _r = bubbleRadiusScale;\n        return _chart;\n    };\n\n    /**\n     * Turn on or off the elastic bubble radius feature, or return the value of the flag. If this\n     * feature is turned on, then bubble radii will be automatically rescaled to fit the chart better.\n     * @method elasticRadius\n     * @memberof dc.bubbleChart\n     * @instance\n     * @param {Boolean} [elasticRadius=false]\n     * @returns {Boolean|dc.bubbleChart}\n     */\n    _chart.elasticRadius = function (elasticRadius) {\n        if (!arguments.length) {\n            return _elasticRadius;\n        }\n        _elasticRadius = elasticRadius;\n        return _chart;\n    };\n\n    _chart.calculateRadiusDomain = function () {\n        if (_elasticRadius) {\n            _chart.r().domain([_chart.rMin(), _chart.rMax()]);\n        }\n    };\n\n    /**\n     * Get or set the radius value accessor function. If set, the radius value accessor function will\n     * be used to retrieve a data value for each bubble. The data retrieved then will be mapped using\n     * the r scale to the actual bubble radius. This allows you to encode a data dimension using bubble\n     * size.\n     * @method radiusValueAccessor\n     * @memberof dc.bubbleMixin\n     * @instance\n     * @param {Function} [radiusValueAccessor]\n     * @returns {Function|dc.bubbleMixin}\n     */\n    _chart.radiusValueAccessor = function (radiusValueAccessor) {\n        if (!arguments.length) {\n            return _rValueAccessor;\n        }\n        _rValueAccessor = radiusValueAccessor;\n        return _chart;\n    };\n\n    _chart.rMin = function () {\n        var min = d3.min(_chart.data(), function (e) {\n            return _chart.radiusValueAccessor()(e);\n        });\n        return min;\n    };\n\n    _chart.rMax = function () {\n        var max = d3.max(_chart.data(), function (e) {\n            return _chart.radiusValueAccessor()(e);\n        });\n        return max;\n    };\n\n    _chart.bubbleR = function (d) {\n        var value = _chart.radiusValueAccessor()(d);\n        var r = _chart.r()(value);\n        if (isNaN(r) || value <= 0) {\n            r = 0;\n        }\n        return r;\n    };\n\n    var labelFunction = function (d) {\n        return _chart.label()(d);\n    };\n\n    var shouldLabel = function (d) {\n        return (_chart.bubbleR(d) > _minRadiusWithLabel);\n    };\n\n    var labelOpacity = function (d) {\n        return shouldLabel(d) ? 1 : 0;\n    };\n\n    var labelPointerEvent = function (d) {\n        return shouldLabel(d) ? 'all' : 'none';\n    };\n\n    _chart._doRenderLabel = function (bubbleGEnter) {\n        if (_chart.renderLabel()) {\n            var label = bubbleGEnter.select('text');\n\n            if (label.empty()) {\n                label = bubbleGEnter.append('text')\n                    .attr('text-anchor', 'middle')\n                    .attr('dy', '.3em')\n                    .on('click', _chart.onClick);\n            }\n\n            label\n                .attr('opacity', 0)\n                .attr('pointer-events', labelPointerEvent)\n                .text(labelFunction);\n            dc.transition(label, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('opacity', labelOpacity);\n        }\n    };\n\n    _chart.doUpdateLabels = function (bubbleGEnter) {\n        if (_chart.renderLabel()) {\n            var labels = bubbleGEnter.select('text')\n                .attr('pointer-events', labelPointerEvent)\n                .text(labelFunction);\n            dc.transition(labels, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('opacity', labelOpacity);\n        }\n    };\n\n    var titleFunction = function (d) {\n        return _chart.title()(d);\n    };\n\n    _chart._doRenderTitles = function (g) {\n        if (_chart.renderTitle()) {\n            var title = g.select('title');\n\n            if (title.empty()) {\n                g.append('title').text(titleFunction);\n            }\n        }\n    };\n\n    _chart.doUpdateTitles = function (g) {\n        if (_chart.renderTitle()) {\n            g.select('title').text(titleFunction);\n        }\n    };\n\n    /**\n     * Turn on or off the bubble sorting feature, or return the value of the flag. If enabled,\n     * bubbles will be sorted by their radius, with smaller bubbles in front.\n     * @method sortBubbleSize\n     * @memberof dc.bubbleChart\n     * @instance\n     * @param {Boolean} [sortBubbleSize=false]\n     * @returns {Boolean|dc.bubbleChart}\n     */\n    _chart.sortBubbleSize = function (sortBubbleSize) {\n        if (!arguments.length) {\n            return _sortBubbleSize;\n        }\n        _sortBubbleSize = sortBubbleSize;\n        return _chart;\n    };\n\n    /**\n     * Get or set the minimum radius. This will be used to initialize the radius scale's range.\n     * @method minRadius\n     * @memberof dc.bubbleMixin\n     * @instance\n     * @param {Number} [radius=10]\n     * @returns {Number|dc.bubbleMixin}\n     */\n    _chart.minRadius = function (radius) {\n        if (!arguments.length) {\n            return _chart.MIN_RADIUS;\n        }\n        _chart.MIN_RADIUS = radius;\n        return _chart;\n    };\n\n    /**\n     * Get or set the minimum radius for label rendering. If a bubble's radius is less than this value\n     * then no label will be rendered.\n     * @method minRadiusWithLabel\n     * @memberof dc.bubbleMixin\n     * @instance\n     * @param {Number} [radius=10]\n     * @returns {Number|dc.bubbleMixin}\n     */\n\n    _chart.minRadiusWithLabel = function (radius) {\n        if (!arguments.length) {\n            return _minRadiusWithLabel;\n        }\n        _minRadiusWithLabel = radius;\n        return _chart;\n    };\n\n    /**\n     * Get or set the maximum relative size of a bubble to the length of x axis. This value is useful\n     * when the difference in radius between bubbles is too great.\n     * @method maxBubbleRelativeSize\n     * @memberof dc.bubbleMixin\n     * @instance\n     * @param {Number} [relativeSize=0.3]\n     * @returns {Number|dc.bubbleMixin}\n     */\n    _chart.maxBubbleRelativeSize = function (relativeSize) {\n        if (!arguments.length) {\n            return _maxBubbleRelativeSize;\n        }\n        _maxBubbleRelativeSize = relativeSize;\n        return _chart;\n    };\n\n    _chart.fadeDeselectedArea = function (selection) {\n        if (_chart.hasFilter()) {\n            _chart.selectAll('g.' + _chart.BUBBLE_NODE_CLASS).each(function (d) {\n                if (_chart.isSelectedNode(d)) {\n                    _chart.highlightSelected(this);\n                } else {\n                    _chart.fadeDeselected(this);\n                }\n            });\n        } else {\n            _chart.selectAll('g.' + _chart.BUBBLE_NODE_CLASS).each(function () {\n                _chart.resetHighlight(this);\n            });\n        }\n    };\n\n    _chart.isSelectedNode = function (d) {\n        return _chart.hasFilter(d.key);\n    };\n\n    _chart.onClick = function (d) {\n        var filter = d.key;\n        dc.events.trigger(function () {\n            _chart.filter(filter);\n            _chart.redrawGroup();\n        });\n    };\n\n    return _chart;\n};\n\n/**\n * The pie chart implementation is usually used to visualize a small categorical distribution.  The pie\n * chart uses keyAccessor to determine the slices, and valueAccessor to calculate the size of each\n * slice relative to the sum of all values. Slices are ordered by {@link dc.baseMixin#ordering ordering}\n * which defaults to sorting by key.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * @class pieChart\n * @memberof dc\n * @mixes dc.capMixin\n * @mixes dc.colorMixin\n * @mixes dc.baseMixin\n * @example\n * // create a pie chart under #chart-container1 element using the default global chart group\n * var chart1 = dc.pieChart('#chart-container1');\n * // create a pie chart under #chart-container2 element using chart group A\n * var chart2 = dc.pieChart('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.pieChart}\n */\ndc.pieChart = function (parent, chartGroup) {\n    var DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5;\n\n    var _sliceCssClass = 'pie-slice';\n    var _labelCssClass = 'pie-label';\n    var _sliceGroupCssClass = 'pie-slice-group';\n    var _labelGroupCssClass = 'pie-label-group';\n    var _emptyCssClass = 'empty-chart';\n    var _emptyTitle = 'empty';\n\n    var _radius,\n        _givenRadius, // specified radius, if any\n        _innerRadius = 0,\n        _externalRadiusPadding = 0;\n\n    var _g;\n    var _cx;\n    var _cy;\n    var _minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL;\n    var _externalLabelRadius;\n    var _drawPaths = false;\n    var _chart = dc.capMixin(dc.colorMixin(dc.baseMixin({})));\n\n    _chart.colorAccessor(_chart.cappedKeyAccessor);\n\n    _chart.title(function (d) {\n        return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d);\n    });\n\n    /**\n     * Get or set the maximum number of slices the pie chart will generate. The top slices are determined by\n     * value from high to low. Other slices exeeding the cap will be rolled up into one single *Others* slice.\n     * @method slicesCap\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [cap]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.slicesCap = _chart.cap;\n\n    _chart.label(_chart.cappedKeyAccessor);\n    _chart.renderLabel(true);\n\n    _chart.transitionDuration(350);\n    _chart.transitionDelay(0);\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n\n        _g = _chart.svg()\n            .append('g')\n            .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')');\n\n        _g.append('g').attr('class', _sliceGroupCssClass);\n        _g.append('g').attr('class', _labelGroupCssClass);\n\n        drawChart();\n\n        return _chart;\n    };\n\n    function drawChart () {\n        // set radius from chart size if none given, or if given radius is too large\n        var maxRadius =  d3.min([_chart.width(), _chart.height()]) / 2;\n        _radius = _givenRadius && _givenRadius < maxRadius ? _givenRadius : maxRadius;\n\n        var arc = buildArcs();\n\n        var pie = pieLayout();\n        var pieData;\n        // if we have data...\n        if (d3.sum(_chart.data(), _chart.valueAccessor())) {\n            pieData = pie(_chart.data());\n            _g.classed(_emptyCssClass, false);\n        } else {\n            // otherwise we'd be getting NaNs, so override\n            // note: abuse others for its ignoring the value accessor\n            pieData = pie([{key: _emptyTitle, value: 1, others: [_emptyTitle]}]);\n            _g.classed(_emptyCssClass, true);\n        }\n\n        if (_g) {\n            var slices = _g.select('g.' + _sliceGroupCssClass)\n                .selectAll('g.' + _sliceCssClass)\n                .data(pieData);\n\n            var labels = _g.select('g.' + _labelGroupCssClass)\n                .selectAll('text.' + _labelCssClass)\n                .data(pieData);\n\n            removeElements(slices, labels);\n\n            createElements(slices, labels, arc, pieData);\n\n            updateElements(pieData, arc);\n\n            highlightFilter();\n\n            dc.transition(_g, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')');\n        }\n    }\n\n    function createElements (slices, labels, arc, pieData) {\n        var slicesEnter = createSliceNodes(slices);\n\n        createSlicePath(slicesEnter, arc);\n\n        createTitles(slicesEnter);\n\n        createLabels(labels, pieData, arc);\n    }\n\n    function createSliceNodes (slices) {\n        var slicesEnter = slices\n            .enter()\n            .append('g')\n            .attr('class', function (d, i) {\n                return _sliceCssClass + ' _' + i;\n            });\n        return slicesEnter;\n    }\n\n    function createSlicePath (slicesEnter, arc) {\n        var slicePath = slicesEnter.append('path')\n            .attr('fill', fill)\n            .on('click', onClick)\n            .attr('d', function (d, i) {\n                return safeArc(d, i, arc);\n            });\n\n        var transition = dc.transition(slicePath, _chart.transitionDuration(), _chart.transitionDelay());\n        if (transition.attrTween) {\n            transition.attrTween('d', tweenPie);\n        }\n    }\n\n    function createTitles (slicesEnter) {\n        if (_chart.renderTitle()) {\n            slicesEnter.append('title').text(function (d) {\n                return _chart.title()(d.data);\n            });\n        }\n    }\n\n    _chart._applyLabelText = function (labels) {\n        labels\n            .text(function (d) {\n                var data = d.data;\n                if ((sliceHasNoData(data) || sliceTooSmall(d)) && !isSelectedSlice(d)) {\n                    return '';\n                }\n                return _chart.label()(d.data);\n            });\n    };\n\n    function positionLabels (labels, arc) {\n        _chart._applyLabelText(labels);\n        dc.transition(labels, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', function (d) {\n                return labelPosition(d, arc);\n            })\n            .attr('text-anchor', 'middle');\n    }\n\n    function highlightSlice (i, whether) {\n        _chart.select('g.pie-slice._' + i)\n            .classed('highlight', whether);\n    }\n\n    function createLabels (labels, pieData, arc) {\n        if (_chart.renderLabel()) {\n            var labelsEnter = labels\n                .enter()\n                .append('text')\n                .attr('class', function (d, i) {\n                    var classes = _sliceCssClass + ' ' + _labelCssClass + ' _' + i;\n                    if (_externalLabelRadius) {\n                        classes += ' external';\n                    }\n                    return classes;\n                })\n                .on('click', onClick)\n                .on('mouseover', function (d, i) {\n                    highlightSlice(i, true);\n                })\n                .on('mouseout', function (d, i) {\n                    highlightSlice(i, false);\n                });\n            positionLabels(labelsEnter, arc);\n            if (_externalLabelRadius && _drawPaths) {\n                updateLabelPaths(pieData, arc);\n            }\n        }\n    }\n\n    function updateLabelPaths (pieData, arc) {\n        var polyline = _g.selectAll('polyline.' + _sliceCssClass)\n                .data(pieData);\n\n        polyline.exit().remove();\n\n        polyline = polyline\n            .enter()\n            .append('polyline')\n            .attr('class', function (d, i) {\n                return 'pie-path _' + i + ' ' + _sliceCssClass;\n            })\n            .on('click', onClick)\n            .on('mouseover', function (d, i) {\n                highlightSlice(i, true);\n            })\n            .on('mouseout', function (d, i) {\n                highlightSlice(i, false);\n            })\n            .merge(polyline);\n\n        var arc2 = d3.arc()\n                .outerRadius(_radius - _externalRadiusPadding + _externalLabelRadius)\n                .innerRadius(_radius - _externalRadiusPadding);\n        var transition = dc.transition(polyline, _chart.transitionDuration(), _chart.transitionDelay());\n        // this is one rare case where d3.selection differs from d3.transition\n        if (transition.attrTween) {\n            transition\n                .attrTween('points', function (d) {\n                    var current = this._current || d;\n                    current = {startAngle: current.startAngle, endAngle: current.endAngle};\n                    var interpolate = d3.interpolate(current, d);\n                    this._current = interpolate(0);\n                    return function (t) {\n                        var d2 = interpolate(t);\n                        return [arc.centroid(d2), arc2.centroid(d2)];\n                    };\n                });\n        } else {\n            transition.attr('points', function (d) {\n                return [arc.centroid(d), arc2.centroid(d)];\n            });\n        }\n        transition.style('visibility', function (d) {\n            return d.endAngle - d.startAngle < 0.0001 ? 'hidden' : 'visible';\n        });\n\n    }\n\n    function updateElements (pieData, arc) {\n        updateSlicePaths(pieData, arc);\n        updateLabels(pieData, arc);\n        updateTitles(pieData);\n    }\n\n    function updateSlicePaths (pieData, arc) {\n        var slicePaths = _g.selectAll('g.' + _sliceCssClass)\n            .data(pieData)\n            .select('path')\n            .attr('d', function (d, i) {\n                return safeArc(d, i, arc);\n            });\n        var transition = dc.transition(slicePaths, _chart.transitionDuration(), _chart.transitionDelay());\n        if (transition.attrTween) {\n            transition.attrTween('d', tweenPie);\n        }\n        transition.attr('fill', fill);\n    }\n\n    function updateLabels (pieData, arc) {\n        if (_chart.renderLabel()) {\n            var labels = _g.selectAll('text.' + _labelCssClass)\n                .data(pieData);\n            positionLabels(labels, arc);\n            if (_externalLabelRadius && _drawPaths) {\n                updateLabelPaths(pieData, arc);\n            }\n        }\n    }\n\n    function updateTitles (pieData) {\n        if (_chart.renderTitle()) {\n            _g.selectAll('g.' + _sliceCssClass)\n                .data(pieData)\n                .select('title')\n                .text(function (d) {\n                    return _chart.title()(d.data);\n                });\n        }\n    }\n\n    function removeElements (slices, labels) {\n        slices.exit().remove();\n        labels.exit().remove();\n    }\n\n    function highlightFilter () {\n        if (_chart.hasFilter()) {\n            _chart.selectAll('g.' + _sliceCssClass).each(function (d) {\n                if (isSelectedSlice(d)) {\n                    _chart.highlightSelected(this);\n                } else {\n                    _chart.fadeDeselected(this);\n                }\n            });\n        } else {\n            _chart.selectAll('g.' + _sliceCssClass).each(function () {\n                _chart.resetHighlight(this);\n            });\n        }\n    }\n\n    /**\n     * Get or set the external radius padding of the pie chart. This will force the radius of the\n     * pie chart to become smaller or larger depending on the value.\n     * @method externalRadiusPadding\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [externalRadiusPadding=0]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.externalRadiusPadding = function (externalRadiusPadding) {\n        if (!arguments.length) {\n            return _externalRadiusPadding;\n        }\n        _externalRadiusPadding = externalRadiusPadding;\n        return _chart;\n    };\n\n    /**\n     * Get or set the inner radius of the pie chart. If the inner radius is greater than 0px then the\n     * pie chart will be rendered as a doughnut chart.\n     * @method innerRadius\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [innerRadius=0]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.innerRadius = function (innerRadius) {\n        if (!arguments.length) {\n            return _innerRadius;\n        }\n        _innerRadius = innerRadius;\n        return _chart;\n    };\n\n    /**\n     * Get or set the outer radius. If the radius is not set, it will be half of the minimum of the\n     * chart width and height.\n     * @method radius\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [radius]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.radius = function (radius) {\n        if (!arguments.length) {\n            return _givenRadius;\n        }\n        _givenRadius = radius;\n        return _chart;\n    };\n\n    /**\n     * Get or set center x coordinate position. Default is center of svg.\n     * @method cx\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [cx]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.cx = function (cx) {\n        if (!arguments.length) {\n            return (_cx ||  _chart.width() / 2);\n        }\n        _cx = cx;\n        return _chart;\n    };\n\n    /**\n     * Get or set center y coordinate position. Default is center of svg.\n     * @method cy\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [cy]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.cy = function (cy) {\n        if (!arguments.length) {\n            return (_cy ||  _chart.height() / 2);\n        }\n        _cy = cy;\n        return _chart;\n    };\n\n    function buildArcs () {\n        return d3.arc()\n            .outerRadius(_radius - _externalRadiusPadding)\n            .innerRadius(_innerRadius);\n    }\n\n    function isSelectedSlice (d) {\n        return _chart.hasFilter(_chart.cappedKeyAccessor(d.data));\n    }\n\n    _chart._doRedraw = function () {\n        drawChart();\n        return _chart;\n    };\n\n    /**\n     * Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not\n     * display a slice label.\n     * @method minAngleForLabel\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [minAngleForLabel=0.5]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.minAngleForLabel = function (minAngleForLabel) {\n        if (!arguments.length) {\n            return _minAngleForLabel;\n        }\n        _minAngleForLabel = minAngleForLabel;\n        return _chart;\n    };\n\n    function pieLayout () {\n        return d3.pie().sort(null).value(_chart.cappedValueAccessor);\n    }\n\n    function sliceTooSmall (d) {\n        var angle = (d.endAngle - d.startAngle);\n        return isNaN(angle) || angle < _minAngleForLabel;\n    }\n\n    function sliceHasNoData (d) {\n        return _chart.cappedValueAccessor(d) === 0;\n    }\n\n    function tweenPie (b) {\n        b.innerRadius = _innerRadius;\n        var current = this._current;\n        if (isOffCanvas(current)) {\n            current = {startAngle: 0, endAngle: 0};\n        } else {\n            // only interpolate startAngle & endAngle, not the whole data object\n            current = {startAngle: current.startAngle, endAngle: current.endAngle};\n        }\n        var i = d3.interpolate(current, b);\n        this._current = i(0);\n        return function (t) {\n            return safeArc(i(t), 0, buildArcs());\n        };\n    }\n\n    function isOffCanvas (current) {\n        return !current || isNaN(current.startAngle) || isNaN(current.endAngle);\n    }\n\n    function fill (d, i) {\n        return _chart.getColor(d.data, i);\n    }\n\n    function onClick (d, i) {\n        if (_g.attr('class') !== _emptyCssClass) {\n            _chart.onClick(d.data, i);\n        }\n    }\n\n    function safeArc (d, i, arc) {\n        var path = arc(d, i);\n        if (path.indexOf('NaN') >= 0) {\n            path = 'M0,0';\n        }\n        return path;\n    }\n\n    /**\n     * Title to use for the only slice when there is no data.\n     * @method emptyTitle\n     * @memberof dc.pieChart\n     * @instance\n     * @param {String} [title]\n     * @returns {String|dc.pieChart}\n     */\n    _chart.emptyTitle = function (title) {\n        if (arguments.length === 0) {\n            return _emptyTitle;\n        }\n        _emptyTitle = title;\n        return _chart;\n    };\n\n    /**\n     * Position slice labels offset from the outer edge of the chart.\n     *\n     * The argument specifies the extra radius to be added for slice labels.\n     * @method externalLabels\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Number} [externalLabelRadius]\n     * @returns {Number|dc.pieChart}\n     */\n    _chart.externalLabels = function (externalLabelRadius) {\n        if (arguments.length === 0) {\n            return _externalLabelRadius;\n        } else if (externalLabelRadius) {\n            _externalLabelRadius = externalLabelRadius;\n        } else {\n            _externalLabelRadius = undefined;\n        }\n\n        return _chart;\n    };\n\n    /**\n     * Get or set whether to draw lines from pie slices to their labels.\n     *\n     * @method drawPaths\n     * @memberof dc.pieChart\n     * @instance\n     * @param {Boolean} [drawPaths]\n     * @returns {Boolean|dc.pieChart}\n     */\n    _chart.drawPaths = function (drawPaths) {\n        if (arguments.length === 0) {\n            return _drawPaths;\n        }\n        _drawPaths = drawPaths;\n        return _chart;\n    };\n\n    function labelPosition (d, arc) {\n        var centroid;\n        if (_externalLabelRadius) {\n            centroid = d3.arc()\n                .outerRadius(_radius - _externalRadiusPadding + _externalLabelRadius)\n                .innerRadius(_radius - _externalRadiusPadding + _externalLabelRadius)\n                .centroid(d);\n        } else {\n            centroid = arc.centroid(d);\n        }\n        if (isNaN(centroid[0]) || isNaN(centroid[1])) {\n            return 'translate(0,0)';\n        } else {\n            return 'translate(' + centroid + ')';\n        }\n    }\n\n    _chart.legendables = function () {\n        return _chart.data().map(function (d, i) {\n            var legendable = {name: d.key, data: d.value, others: d.others, chart: _chart};\n            legendable.color = _chart.getColor(d, i);\n            return legendable;\n        });\n    };\n\n    _chart.legendHighlight = function (d) {\n        highlightSliceFromLegendable(d, true);\n    };\n\n    _chart.legendReset = function (d) {\n        highlightSliceFromLegendable(d, false);\n    };\n\n    _chart.legendToggle = function (d) {\n        _chart.onClick({key: d.name, others: d.others});\n    };\n\n    function highlightSliceFromLegendable (legendable, highlighted) {\n        _chart.selectAll('g.pie-slice').each(function (d) {\n            if (legendable.name === d.data.key) {\n                d3.select(this).classed('highlight', highlighted);\n            }\n        });\n    }\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The sunburst chart implementation is usually used to visualize a small tree distribution.  The sunburst\n * chart uses keyAccessor to determine the slices, and valueAccessor to calculate the size of each\n * slice relative to the sum of all values. Slices are ordered by {@link dc.baseMixin#ordering ordering} which defaults to sorting\n * by key.\n *\n * The keys used in the sunburst chart should be arrays, representing paths in the tree.\n *\n * When filtering, the sunburst chart creates instances of {@link dc.filters.HierarchyFilter HierarchyFilter}.\n *\n * @class sunburstChart\n * @memberof dc\n * @mixes dc.capMixin\n * @mixes dc.colorMixin\n * @mixes dc.baseMixin\n * @example\n * // create a sunburst chart under #chart-container1 element using the default global chart group\n * var chart1 = dc.sunburstChart('#chart-container1');\n * // create a sunburst chart under #chart-container2 element using chart group A\n * var chart2 = dc.sunburstChart('#chart-container2', 'chartGroupA');\n *\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-3.x-api-reference/blob/master/Selections.md#selecting-elements d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.sunburstChart}\n **/\ndc.sunburstChart = function (parent, chartGroup) {\n    var DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5;\n\n    var _sliceCssClass = 'pie-slice';\n    var _emptyCssClass = 'empty-chart';\n    var _emptyTitle = 'empty';\n\n    var _radius,\n        _innerRadius = 0;\n\n    var _g;\n    var _cx;\n    var _cy;\n    var _minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL;\n    var _externalLabelRadius;\n    var _chart = dc.capMixin(dc.colorMixin(dc.baseMixin({})));\n\n    _chart.colorAccessor(_chart.cappedKeyAccessor);\n\n    _chart.title(function (d) {\n        return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d);\n    });\n\n    _chart.label(_chart.cappedKeyAccessor);\n    _chart.renderLabel(true);\n\n    _chart.transitionDuration(350);\n\n    _chart.filterHandler(function (dimension, filters) {\n        if (filters.length === 0) {\n            dimension.filter(null);\n        } else {\n            dimension.filterFunction(function (d) {\n                for (var i = 0; i < filters.length; i++) {\n                    var filter = filters[i];\n                    if (filter.isFiltered && filter.isFiltered(d)) {\n                        return true;\n                    }\n                }\n                return false;\n            });\n        }\n        return filters;\n    });\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n\n        _g = _chart.svg()\n            .append('g')\n            .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')');\n\n        drawChart();\n\n        return _chart;\n    };\n\n    function drawChart () {\n        // set radius on basis of chart dimension if missing\n        _radius = _radius ? _radius : d3.min([_chart.width(), _chart.height()]) / 2;\n\n        var arc = buildArcs();\n\n        var sunburstData, cdata;\n        // if we have data...\n        if (d3.sum(_chart.data(), _chart.valueAccessor())) {\n            cdata = dc.utils.toHierarchy(_chart.data(), _chart.valueAccessor());\n            sunburstData = partitionNodes(cdata);\n            // First one is the root, which is not needed\n            sunburstData.shift();\n            _g.classed(_emptyCssClass, false);\n        } else {\n            // otherwise we'd be getting NaNs, so override\n            // note: abuse others for its ignoring the value accessor\n            cdata = dc.utils.toHierarchy([], function (d) {\n                return d.value;\n            });\n            sunburstData = partitionNodes(cdata);\n            _g.classed(_emptyCssClass, true);\n        }\n\n        if (_g) {\n            var slices = _g.selectAll('g.' + _sliceCssClass)\n                .data(sunburstData);\n            createElements(slices, arc, sunburstData);\n\n            updateElements(sunburstData, arc);\n\n            removeElements(slices);\n\n            highlightFilter();\n        }\n    }\n\n    function createElements (slices, arc, sunburstData) {\n        var slicesEnter = createSliceNodes(slices);\n\n        createSlicePath(slicesEnter, arc);\n        createTitles(slicesEnter);\n        createLabels(sunburstData, arc);\n    }\n\n    function createSliceNodes (slices) {\n        var slicesEnter = slices\n            .enter()\n            .append('g')\n            .attr('class', function (d, i) {\n                return _sliceCssClass +\n                    ' _' + i + ' ' +\n                    _sliceCssClass + '-level-' + d.depth;\n            });\n        return slicesEnter;\n    }\n\n    function createSlicePath (slicesEnter, arc) {\n        var slicePath = slicesEnter.append('path')\n            .attr('fill', fill)\n            .on('click', onClick)\n            .attr('d', function (d, i) {\n                return safeArc(d, i, arc);\n            });\n\n        var transition = dc.transition(slicePath, _chart.transitionDuration());\n        if (transition.attrTween) {\n            transition.attrTween('d', tweenSlice);\n        }\n    }\n\n    function createTitles (slicesEnter) {\n        if (_chart.renderTitle()) {\n            slicesEnter.append('title').text(function (d) {\n                return _chart.title()(d);\n            });\n        }\n    }\n\n    function positionLabels (labelsEnter, arc) {\n        dc.transition(labelsEnter, _chart.transitionDuration())\n            .attr('transform', function (d) {\n                return labelPosition(d, arc);\n            })\n            .attr('text-anchor', 'middle')\n            .text(function (d) {\n                // position label...\n                if (sliceHasNoData(d) || sliceTooSmall(d)) {\n                    return '';\n                }\n                return _chart.label()(d);\n            });\n    }\n\n    function createLabels (sunburstData, arc) {\n        if (_chart.renderLabel()) {\n            var labels = _g.selectAll('text.' + _sliceCssClass)\n                .data(sunburstData);\n\n            labels.exit().remove();\n\n            var labelsEnter = labels\n                .enter()\n                .append('text')\n                .attr('class', function (d, i) {\n                    var classes = _sliceCssClass + ' _' + i;\n                    if (_externalLabelRadius) {\n                        classes += ' external';\n                    }\n                    return classes;\n                })\n                .on('click', onClick);\n            positionLabels(labelsEnter, arc);\n        }\n    }\n\n    function updateElements (sunburstData, arc) {\n        updateSlicePaths(sunburstData, arc);\n        updateLabels(sunburstData, arc);\n        updateTitles(sunburstData);\n    }\n\n    function updateSlicePaths (sunburstData, arc) {\n        var slicePaths = _g.selectAll('g.' + _sliceCssClass)\n            .data(sunburstData)\n            .select('path')\n            .attr('d', function (d, i) {\n                return safeArc(d, i, arc);\n            });\n        var transition = dc.transition(slicePaths, _chart.transitionDuration());\n        if (transition.attrTween) {\n            transition.attrTween('d', tweenSlice);\n        }\n        transition.attr('fill', fill);\n    }\n\n    function updateLabels (sunburstData, arc) {\n        if (_chart.renderLabel()) {\n            var labels = _g.selectAll('text.' + _sliceCssClass)\n                .data(sunburstData);\n            positionLabels(labels, arc);\n        }\n    }\n\n    function updateTitles (sunburstData) {\n        if (_chart.renderTitle()) {\n            _g.selectAll('g.' + _sliceCssClass)\n                .data(sunburstData)\n                .select('title')\n                .text(function (d) {\n                    return _chart.title()(d);\n                });\n        }\n    }\n\n    function removeElements (slices) {\n        slices.exit().remove();\n    }\n\n    function highlightFilter () {\n        if (_chart.hasFilter()) {\n            _chart.selectAll('g.' + _sliceCssClass).each(function (d) {\n                if (isSelectedSlice(d)) {\n                    _chart.highlightSelected(this);\n                } else {\n                    _chart.fadeDeselected(this);\n                }\n            });\n        } else {\n            _chart.selectAll('g.' + _sliceCssClass).each(function (d) {\n                _chart.resetHighlight(this);\n            });\n        }\n    }\n\n    /**\n     * Get or set the inner radius of the sunburst chart. If the inner radius is greater than 0px then the\n     * sunburst chart will be rendered as a doughnut chart. Default inner radius is 0px.\n     * @method innerRadius\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [innerRadius=0]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.innerRadius = function (innerRadius) {\n        if (!arguments.length) {\n            return _innerRadius;\n        }\n        _innerRadius = innerRadius;\n        return _chart;\n    };\n\n    /**\n     * Get or set the outer radius. If the radius is not set, it will be half of the minimum of the\n     * chart width and height.\n     * @method radius\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [radius]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.radius = function (radius) {\n        if (!arguments.length) {\n            return _radius;\n        }\n        _radius = radius;\n        return _chart;\n    };\n\n    /**\n     * Get or set center x coordinate position. Default is center of svg.\n     * @method cx\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [cx]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.cx = function (cx) {\n        if (!arguments.length) {\n            return (_cx || _chart.width() / 2);\n        }\n        _cx = cx;\n        return _chart;\n    };\n\n    /**\n     * Get or set center y coordinate position. Default is center of svg.\n     * @method cy\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [cy]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.cy = function (cy) {\n        if (!arguments.length) {\n            return (_cy || _chart.height() / 2);\n        }\n        _cy = cy;\n        return _chart;\n    };\n\n    /**\n     * Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not\n     * display a slice label.\n     * @method minAngleForLabel\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [minAngleForLabel=0.5]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.minAngleForLabel = function (minAngleForLabel) {\n        if (!arguments.length) {\n            return _minAngleForLabel;\n        }\n        _minAngleForLabel = minAngleForLabel;\n        return _chart;\n    };\n\n    /**\n     * Title to use for the only slice when there is no data.\n     * @method emptyTitle\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {String} [title]\n     * @returns {String|dc.sunburstChart}\n     */\n    _chart.emptyTitle = function (title) {\n        if (arguments.length === 0) {\n            return _emptyTitle;\n        }\n        _emptyTitle = title;\n        return _chart;\n    };\n\n    /**\n     * Position slice labels offset from the outer edge of the chart.\n     *\n     * The argument specifies the extra radius to be added for slice labels.\n     * @method externalLabels\n     * @memberof dc.sunburstChart\n     * @instance\n     * @param {Number} [externalLabelRadius]\n     * @returns {Number|dc.sunburstChart}\n     */\n    _chart.externalLabels = function (externalLabelRadius) {\n        if (arguments.length === 0) {\n            return _externalLabelRadius;\n        } else if (externalLabelRadius) {\n            _externalLabelRadius = externalLabelRadius;\n        } else {\n            _externalLabelRadius = undefined;\n        }\n\n        return _chart;\n    };\n\n    function buildArcs () {\n        return d3.arc()\n            .startAngle(function (d) {\n                return d.x0;\n            })\n            .endAngle(function (d) {\n                return d.x1;\n            })\n            .innerRadius(function (d) {\n                return d.data.path && d.data.path.length === 1 ? _innerRadius : Math.sqrt(d.y0);\n            })\n            .outerRadius(function (d) {\n                return Math.sqrt(d.y1);\n            });\n    }\n\n    function isSelectedSlice (d) {\n        return isPathFiltered(d.path);\n    }\n\n    function isPathFiltered (path) {\n        for (var i = 0; i < _chart.filters().length; i++) {\n            var currentFilter = _chart.filters()[i];\n            if (currentFilter.isFiltered(path)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // returns all filters that are a parent or child of the path\n    function filtersForPath (path) {\n        var pathFilter = dc.filters.HierarchyFilter(path);\n        var filters = [];\n        for (var i = 0; i < _chart.filters().length; i++) {\n            var currentFilter = _chart.filters()[i];\n            if (currentFilter.isFiltered(path) || pathFilter.isFiltered(currentFilter)) {\n                filters.push(currentFilter);\n            }\n        }\n        return filters;\n    }\n\n    _chart._doRedraw = function () {\n        drawChart();\n        return _chart;\n    };\n\n    function partitionNodes (data) {\n        // The changes picked up from https://github.com/d3/d3-hierarchy/issues/50\n        var hierarchy = d3.hierarchy(data)\n            .sum(function (d) {\n                return d.children ? 0 : _chart.cappedValueAccessor(d);\n            })\n            .sort(function (a, b) {\n                return d3.ascending(a.data.path, b.data.path);\n            });\n\n        var partition = d3.partition()\n            .size([2 * Math.PI, _radius * _radius]);\n\n        partition(hierarchy);\n\n        // In D3v4 the returned data is slightly different, change it enough to suit our purposes.\n        var nodes = hierarchy.descendants().map(function (d) {\n            d.key = d.data.key;\n            d.path = d.data.path;\n            return d;\n        });\n\n        return nodes;\n    }\n\n    function sliceTooSmall (d) {\n        var angle = d.x1 - d.x0;\n        return isNaN(angle) || angle < _minAngleForLabel;\n    }\n\n    function sliceHasNoData (d) {\n        return _chart.cappedValueAccessor(d) === 0;\n    }\n\n    function tweenSlice (b) {\n        b.innerRadius = _innerRadius; //?\n        var current = this._current;\n        if (isOffCanvas(current)) {\n            current = {x: 0, y: 0, dx: 0, dy: 0};\n        }\n        // unfortunally, we can't tween an entire hierarchy since it has 2 way links.\n        var tweenTarget = {x: b.x, y: b.y, dx: b.dx, dy: b.dy};\n        var i = d3.interpolate(current, tweenTarget);\n        this._current = i(0);\n        return function (t) {\n            return safeArc(Object.assign({}, b, i(t)), 0, buildArcs());\n        };\n    }\n\n    function isOffCanvas (current) {\n        return !current || isNaN(current.dx) || isNaN(current.dy);\n    }\n\n    function fill (d, i) {\n        return _chart.getColor(d, i);\n    }\n\n    function _onClick (d) {\n        // Clicking on Legends do not filter, it throws exception\n        // Must be better way to handle this, in legends we need to access `d.key`\n        var path = d.path || d.key;\n        var filter = dc.filters.HierarchyFilter(path);\n\n        // filters are equal to, parents or children of the path.\n        var filters = filtersForPath(path);\n        var exactMatch = false;\n        // clear out any filters that cover the path filtered.\n        for (var i = filters.length - 1; i >= 0; i--) {\n            var currentFilter = filters[i];\n            if (dc.utils.arraysIdentical(currentFilter, path)) {\n                exactMatch = true;\n            }\n            _chart.filter(filters[i]);\n        }\n        dc.events.trigger(function () {\n            // if it is a new filter - put it in.\n            if (!exactMatch) {\n                _chart.filter(filter);\n            }\n            _chart.redrawGroup();\n        });\n    }\n\n    _chart.onClick = onClick;\n\n    function onClick (d, i) {\n        if (_g.attr('class') !== _emptyCssClass) {\n            _onClick(d, i);\n        }\n    }\n\n    function safeArc (d, i, arc) {\n        var path = arc(d, i);\n        if (path.indexOf('NaN') >= 0) {\n            path = 'M0,0';\n        }\n        return path;\n    }\n\n    function labelPosition (d, arc) {\n        var centroid;\n        if (_externalLabelRadius) {\n            centroid = d3.svg.arc()\n                .outerRadius(_radius + _externalLabelRadius)\n                .innerRadius(_radius + _externalLabelRadius)\n                .centroid(d);\n        } else {\n            centroid = arc.centroid(d);\n        }\n        if (isNaN(centroid[0]) || isNaN(centroid[1])) {\n            return 'translate(0,0)';\n        } else {\n            return 'translate(' + centroid + ')';\n        }\n    }\n\n    _chart.legendables = function () {\n        return _chart.data().map(function (d, i) {\n            var legendable = {name: d.key, data: d.value, others: d.others, chart: _chart};\n            legendable.color = _chart.getColor(d, i);\n            return legendable;\n        });\n    };\n\n    _chart.legendHighlight = function (d) {\n        highlightSliceFromLegendable(d, true);\n    };\n\n    _chart.legendReset = function (d) {\n        highlightSliceFromLegendable(d, false);\n    };\n\n    _chart.legendToggle = function (d) {\n        _chart.onClick({key: d.name, others: d.others});\n    };\n\n    function highlightSliceFromLegendable (legendable, highlighted) {\n        _chart.selectAll('g.pie-slice').each(function (d) {\n            if (legendable.name === d.key) {\n                d3.select(this).classed('highlight', highlighted);\n            }\n        });\n    }\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Concrete bar chart/histogram implementation.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats}\n * @class barChart\n * @memberof dc\n * @mixes dc.stackMixin\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a bar chart under #chart-container1 element using the default global chart group\n * var chart1 = dc.barChart('#chart-container1');\n * // create a bar chart under #chart-container2 element using chart group A\n * var chart2 = dc.barChart('#chart-container2', 'chartGroupA');\n * // create a sub-chart under a composite parent chart\n * var chart3 = dc.barChart(compositeChart);\n * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector}\n * specifying a dom block element such as a div; or a dom element or d3 selection.  If the bar\n * chart is a sub-chart in a {@link dc.compositeChart Composite Chart} then pass in the parent\n * composite chart instance instead.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.barChart}\n */\ndc.barChart = function (parent, chartGroup) {\n    var MIN_BAR_WIDTH = 1;\n    var DEFAULT_GAP_BETWEEN_BARS = 2;\n    var LABEL_PADDING = 3;\n\n    var _chart = dc.stackMixin(dc.coordinateGridMixin({}));\n\n    var _gap = DEFAULT_GAP_BETWEEN_BARS;\n    var _centerBar = false;\n    var _alwaysUseRounding = false;\n\n    var _barWidth;\n\n    dc.override(_chart, 'rescale', function () {\n        _chart._rescale();\n        _barWidth = undefined;\n        return _chart;\n    });\n\n    dc.override(_chart, 'render', function () {\n        if (_chart.round() && _centerBar && !_alwaysUseRounding) {\n            dc.logger.warn('By default, brush rounding is disabled if bars are centered. ' +\n                         'See dc.js bar chart API documentation for details.');\n        }\n\n        return _chart._render();\n    });\n\n    _chart.label(function (d) {\n        return dc.utils.printSingleValue(d.y0 + d.y);\n    }, false);\n\n    _chart.plotData = function () {\n        var layers = _chart.chartBodyG().selectAll('g.stack')\n            .data(_chart.data());\n\n        calculateBarWidth();\n\n        layers = layers\n            .enter()\n                .append('g')\n                .attr('class', function (d, i) {\n                    return 'stack ' + '_' + i;\n                })\n            .merge(layers);\n\n        var last = layers.size() - 1;\n        layers.each(function (d, i) {\n            var layer = d3.select(this);\n\n            renderBars(layer, i, d);\n\n            if (_chart.renderLabel() && last === i) {\n                renderLabels(layer, i, d);\n            }\n        });\n    };\n\n    function barHeight (d) {\n        return dc.utils.safeNumber(Math.abs(_chart.y()(d.y + d.y0) - _chart.y()(d.y0)));\n    }\n\n    function labelXPos (d) {\n        var x = _chart.x()(d.x);\n        if (!_centerBar) {\n            x += _barWidth / 2;\n        }\n        if (_chart.isOrdinal() && _gap !== undefined) {\n            x += _gap / 2;\n        }\n        return dc.utils.safeNumber(x);\n    }\n\n    function labelYPos (d) {\n        var y = _chart.y()(d.y + d.y0);\n\n        if (d.y < 0) {\n            y -= barHeight(d);\n        }\n\n        return dc.utils.safeNumber(y - LABEL_PADDING);\n    }\n\n    function renderLabels (layer, layerIndex, d) {\n        var labels = layer.selectAll('text.barLabel')\n            .data(d.values, dc.pluck('x'));\n\n        var labelsEnterUpdate = labels\n            .enter()\n                .append('text')\n                .attr('class', 'barLabel')\n                .attr('text-anchor', 'middle')\n                .attr('x', labelXPos)\n                .attr('y', labelYPos)\n            .merge(labels);\n\n        if (_chart.isOrdinal()) {\n            labelsEnterUpdate.on('click', _chart.onClick);\n            labelsEnterUpdate.attr('cursor', 'pointer');\n        }\n\n        dc.transition(labelsEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('x', labelXPos)\n            .attr('y', labelYPos)\n            .text(function (d) {\n                return _chart.label()(d);\n            });\n\n        dc.transition(labels.exit(), _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('height', 0)\n            .remove();\n    }\n\n    function barXPos (d) {\n        var x = _chart.x()(d.x);\n        if (_centerBar) {\n            x -= _barWidth / 2;\n        }\n        if (_chart.isOrdinal() && _gap !== undefined) {\n            x += _gap / 2;\n        }\n        return dc.utils.safeNumber(x);\n    }\n\n    function renderBars (layer, layerIndex, d) {\n        var bars = layer.selectAll('rect.bar')\n            .data(d.values, dc.pluck('x'));\n\n        var enter = bars.enter()\n            .append('rect')\n            .attr('class', 'bar')\n            .attr('fill', dc.pluck('data', _chart.getColor))\n            .attr('x', barXPos)\n            .attr('y', _chart.yAxisHeight())\n            .attr('height', 0);\n\n        var barsEnterUpdate = enter.merge(bars);\n\n        if (_chart.renderTitle()) {\n            enter.append('title').text(dc.pluck('data', _chart.title(d.name)));\n        }\n\n        if (_chart.isOrdinal()) {\n            barsEnterUpdate.on('click', _chart.onClick);\n        }\n\n        dc.transition(barsEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('x', barXPos)\n            .attr('y', function (d) {\n                var y = _chart.y()(d.y + d.y0);\n\n                if (d.y < 0) {\n                    y -= barHeight(d);\n                }\n\n                return dc.utils.safeNumber(y);\n            })\n            .attr('width', _barWidth)\n            .attr('height', function (d) {\n                return barHeight(d);\n            })\n            .attr('fill', dc.pluck('data', _chart.getColor))\n            .select('title').text(dc.pluck('data', _chart.title(d.name)));\n\n        dc.transition(bars.exit(), _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('x', function (d) { return _chart.x()(d.x); })\n            .attr('width', _barWidth * 0.9)\n            .remove();\n    }\n\n    function calculateBarWidth () {\n        if (_barWidth === undefined) {\n            var numberOfBars = _chart.xUnitCount();\n\n            // please can't we always use rangeBands for bar charts?\n            if (_chart.isOrdinal() && _gap === undefined) {\n                _barWidth = Math.floor(_chart.x().bandwidth());\n            } else if (_gap) {\n                _barWidth = Math.floor((_chart.xAxisLength() - (numberOfBars - 1) * _gap) / numberOfBars);\n            } else {\n                _barWidth = Math.floor(_chart.xAxisLength() / (1 + _chart.barPadding()) / numberOfBars);\n            }\n\n            if (_barWidth === Infinity || isNaN(_barWidth) || _barWidth < MIN_BAR_WIDTH) {\n                _barWidth = MIN_BAR_WIDTH;\n            }\n        }\n    }\n\n    _chart.fadeDeselectedArea = function (brushSelection) {\n        var bars = _chart.chartBodyG().selectAll('rect.bar');\n\n        if (_chart.isOrdinal()) {\n            if (_chart.hasFilter()) {\n                bars.classed(dc.constants.SELECTED_CLASS, function (d) {\n                    return _chart.hasFilter(d.x);\n                });\n                bars.classed(dc.constants.DESELECTED_CLASS, function (d) {\n                    return !_chart.hasFilter(d.x);\n                });\n            } else {\n                bars.classed(dc.constants.SELECTED_CLASS, false);\n                bars.classed(dc.constants.DESELECTED_CLASS, false);\n            }\n        } else if (_chart.brushOn() || _chart.parentBrushOn()) {\n            if (!_chart.brushIsEmpty(brushSelection)) {\n                var start = brushSelection[0];\n                var end = brushSelection[1];\n\n                bars.classed(dc.constants.DESELECTED_CLASS, function (d) {\n                    return d.x < start || d.x >= end;\n                });\n            } else {\n                bars.classed(dc.constants.DESELECTED_CLASS, false);\n            }\n        }\n    };\n\n    /**\n     * Whether the bar chart will render each bar centered around the data position on the x-axis.\n     * @method centerBar\n     * @memberof dc.barChart\n     * @instance\n     * @param {Boolean} [centerBar=false]\n     * @returns {Boolean|dc.barChart}\n     */\n    _chart.centerBar = function (centerBar) {\n        if (!arguments.length) {\n            return _centerBar;\n        }\n        _centerBar = centerBar;\n        return _chart;\n    };\n\n    dc.override(_chart, 'onClick', function (d) {\n        _chart._onClick(d.data);\n    });\n\n    /**\n     * Get or set the spacing between bars as a fraction of bar size. Valid values are between 0-1.\n     * Setting this value will also remove any previously set {@link dc.barChart#gap gap}. See the\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs}\n     * for a visual description of how the padding is applied.\n     * @method barPadding\n     * @memberof dc.barChart\n     * @instance\n     * @param {Number} [barPadding=0]\n     * @returns {Number|dc.barChart}\n     */\n    _chart.barPadding = function (barPadding) {\n        if (!arguments.length) {\n            return _chart._rangeBandPadding();\n        }\n        _chart._rangeBandPadding(barPadding);\n        _gap = undefined;\n        return _chart;\n    };\n\n    _chart._useOuterPadding = function () {\n        return _gap === undefined;\n    };\n\n    /**\n     * Get or set the outer padding on an ordinal bar chart. This setting has no effect on non-ordinal charts.\n     * Will pad the width by `padding * barWidth` on each side of the chart.\n     * @method outerPadding\n     * @memberof dc.barChart\n     * @instance\n     * @param {Number} [padding=0.5]\n     * @returns {Number|dc.barChart}\n     */\n    _chart.outerPadding = _chart._outerRangeBandPadding;\n\n    /**\n     * Manually set fixed gap (in px) between bars instead of relying on the default auto-generated\n     * gap.  By default the bar chart implementation will calculate and set the gap automatically\n     * based on the number of data points and the length of the x axis.\n     * @method gap\n     * @memberof dc.barChart\n     * @instance\n     * @param {Number} [gap=2]\n     * @returns {Number|dc.barChart}\n     */\n    _chart.gap = function (gap) {\n        if (!arguments.length) {\n            return _gap;\n        }\n        _gap = gap;\n        return _chart;\n    };\n\n    _chart.extendBrush = function (brushSelection) {\n        if (brushSelection && _chart.round() && (!_centerBar || _alwaysUseRounding)) {\n            brushSelection[0] = _chart.round()(brushSelection[0]);\n            brushSelection[1] = _chart.round()(brushSelection[1]);\n        }\n        return brushSelection;\n    };\n\n    /**\n     * Set or get whether rounding is enabled when bars are centered. If false, using\n     * rounding with centered bars will result in a warning and rounding will be ignored.  This flag\n     * has no effect if bars are not {@link dc.barChart#centerBar centered}.\n     * When using standard d3.js rounding methods, the brush often doesn't align correctly with\n     * centered bars since the bars are offset.  The rounding function must add an offset to\n     * compensate, such as in the following example.\n     * @method alwaysUseRounding\n     * @memberof dc.barChart\n     * @instance\n     * @example\n     * chart.round(function(n) { return Math.floor(n) + 0.5; });\n     * @param {Boolean} [alwaysUseRounding=false]\n     * @returns {Boolean|dc.barChart}\n     */\n    _chart.alwaysUseRounding = function (alwaysUseRounding) {\n        if (!arguments.length) {\n            return _alwaysUseRounding;\n        }\n        _alwaysUseRounding = alwaysUseRounding;\n        return _chart;\n    };\n\n    function colorFilter (color, inv) {\n        return function () {\n            var item = d3.select(this);\n            var match = item.attr('fill') === color;\n            return inv ? !match : match;\n        };\n    }\n\n    _chart.legendHighlight = function (d) {\n        if (!_chart.isLegendableHidden(d)) {\n            _chart.g().selectAll('rect.bar')\n                .classed('highlight', colorFilter(d.color))\n                .classed('fadeout', colorFilter(d.color, true));\n        }\n    };\n\n    _chart.legendReset = function () {\n        _chart.g().selectAll('rect.bar')\n            .classed('highlight', false)\n            .classed('fadeout', false);\n    };\n\n    dc.override(_chart, 'xAxisMax', function () {\n        var max = this._xAxisMax();\n        if ('resolution' in _chart.xUnits()) {\n            var res = _chart.xUnits().resolution;\n            max += res;\n        }\n        return max;\n    });\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Concrete line/area chart implementation.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats}\n * @class lineChart\n * @memberof dc\n * @mixes dc.stackMixin\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a line chart under #chart-container1 element using the default global chart group\n * var chart1 = dc.lineChart('#chart-container1');\n * // create a line chart under #chart-container2 element using chart group A\n * var chart2 = dc.lineChart('#chart-container2', 'chartGroupA');\n * // create a sub-chart under a composite parent chart\n * var chart3 = dc.lineChart(compositeChart);\n * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector}\n * specifying a dom block element such as a div; or a dom element or d3 selection.  If the line\n * chart is a sub-chart in a {@link dc.compositeChart Composite Chart} then pass in the parent\n * composite chart instance instead.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.lineChart}\n */\ndc.lineChart = function (parent, chartGroup) {\n    var DEFAULT_DOT_RADIUS = 5;\n    var TOOLTIP_G_CLASS = 'dc-tooltip';\n    var DOT_CIRCLE_CLASS = 'dot';\n    var Y_AXIS_REF_LINE_CLASS = 'yRef';\n    var X_AXIS_REF_LINE_CLASS = 'xRef';\n    var DEFAULT_DOT_OPACITY = 1e-6;\n    var LABEL_PADDING = 3;\n\n    var _chart = dc.stackMixin(dc.coordinateGridMixin({}));\n    var _renderArea = false;\n    var _dotRadius = DEFAULT_DOT_RADIUS;\n    var _dataPointRadius = null;\n    var _dataPointFillOpacity = DEFAULT_DOT_OPACITY;\n    var _dataPointStrokeOpacity = DEFAULT_DOT_OPACITY;\n    var _curve = null;\n    var _interpolate = null; // d3.curveLinear;  // deprecated in 3.0\n    var _tension = null;  // deprecated in 3.0\n    var _defined;\n    var _dashStyle;\n    var _xyTipsOn = true;\n\n    _chart.transitionDuration(500);\n    _chart.transitionDelay(0);\n    _chart._rangeBandPadding(1);\n\n    _chart.plotData = function () {\n        var chartBody = _chart.chartBodyG();\n        var layersList = chartBody.select('g.stack-list');\n\n        if (layersList.empty()) {\n            layersList = chartBody.append('g').attr('class', 'stack-list');\n        }\n\n        var layers = layersList.selectAll('g.stack').data(_chart.data());\n\n        var layersEnter = layers\n            .enter()\n            .append('g')\n            .attr('class', function (d, i) {\n                return 'stack ' + '_' + i;\n            });\n\n        layers = layersEnter.merge(layers);\n\n        drawLine(layersEnter, layers);\n\n        drawArea(layersEnter, layers);\n\n        drawDots(chartBody, layers);\n\n        if (_chart.renderLabel()) {\n            drawLabels(layers);\n        }\n    };\n\n    /**\n     * Gets or sets the curve factory to use for lines and areas drawn, allowing e.g. step\n     * functions, splines, and cubic interpolation. Typically you would use one of the interpolator functions\n     * provided by {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curves}.\n     *\n     * Replaces the use of {@link dc.lineChart#interpolate} and {@link dc.lineChart#tension}\n     * in dc.js < 3.0\n     *\n     * This is passed to\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve} and\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve}.\n     * @example\n     * // default\n     * chart\n     *     .curve(d3.curveLinear);\n     * // Add tension to curves that support it\n     * chart\n     *     .curve(d3.curveCardinal.tension(0.5));\n     * // You can use some specialized variation like\n     * // https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline\n     * chart\n     *     .curve(d3.curveCatmullRom.alpha(0.5));\n     * @method curve\n     * @memberof dc.lineChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve}\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve}\n     * @param  {d3.curve} [curve=d3.curveLinear]\n     * @returns {d3.curve|dc.lineChart}\n     */\n    _chart.curve = function (curve) {\n        if (!arguments.length) {\n            return _curve;\n        }\n        _curve = curve;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the interpolator to use for lines drawn, by string name, allowing e.g. step\n     * functions, splines, and cubic interpolation.\n     *\n     * Possible values are: 'linear', 'linear-closed', 'step', 'step-before', 'step-after', 'basis',\n     * 'basis-open', 'basis-closed', 'bundle', 'cardinal', 'cardinal-open', 'cardinal-closed', and\n     * 'monotone'.\n     *\n     * This function exists for backward compatibility. Use {@link dc.lineChart#curve}\n     * which is generic and provides more options.\n     * Value set through `.curve` takes precedence over `.interpolate` and `.tension`.\n     * @method interpolate\n     * @memberof dc.lineChart\n     * @instance\n     * @deprecated since version 3.0 use {@link dc.lineChart#curve} instead\n     * @see {@link dc.lineChart#curve}\n     * @param  {d3.curve} [interpolate=d3.curveLinear]\n     * @returns {d3.curve|dc.lineChart}\n     */\n    _chart.interpolate = dc.logger.deprecate(function (interpolate) {\n        if (!arguments.length) {\n            return _interpolate;\n        }\n        _interpolate = interpolate;\n        return _chart;\n    }, 'dc.lineChart.interpolate has been deprecated since version 3.0 use dc.lineChart.curve instead');\n\n    /**\n     * Gets or sets the tension to use for lines drawn, in the range 0 to 1.\n     *\n     * Passed to the {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curve function}\n     * if it provides a `.tension` function. Example:\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#curveCardinal_tension curveCardinal.tension}.\n     *\n     * This function exists for backward compatibility. Use {@link dc.lineChart#curve}\n     * which is generic and provides more options.\n     * Value set through `.curve` takes precedence over `.interpolate` and `.tension`.\n     * @method tension\n     * @memberof dc.lineChart\n     * @instance\n     * @deprecated since version 3.0 use {@link dc.lineChart#curve} instead\n     * @see {@link dc.lineChart#curve}\n     * @param  {Number} [tension=0]\n     * @returns {Number|dc.lineChart}\n     */\n    _chart.tension = dc.logger.deprecate(function (tension) {\n        if (!arguments.length) {\n            return _tension;\n        }\n        _tension = tension;\n        return _chart;\n    }, 'dc.lineChart.tension has been deprecated since version 3.0 use dc.lineChart.curve instead');\n\n    /**\n     * Gets or sets a function that will determine discontinuities in the line which should be\n     * skipped: the path will be broken into separate subpaths if some points are undefined.\n     * This function is passed to\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined}\n     *\n     * Note: crossfilter will sometimes coerce nulls to 0, so you may need to carefully write\n     * custom reduce functions to get this to work, depending on your data. See\n     * {@link https://github.com/dc-js/dc.js/issues/615#issuecomment-49089248 this GitHub comment}\n     * for more details and an example.\n     * @method defined\n     * @memberof dc.lineChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined}\n     * @param  {Function} [defined]\n     * @returns {Function|dc.lineChart}\n     */\n    _chart.defined = function (defined) {\n        if (!arguments.length) {\n            return _defined;\n        }\n        _defined = defined;\n        return _chart;\n    };\n\n    /**\n     * Set the line's d3 dashstyle. This value becomes the 'stroke-dasharray' of line. Defaults to empty\n     * array (solid line).\n     * @method dashStyle\n     * @memberof dc.lineChart\n     * @instance\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray stroke-dasharray}\n     * @example\n     * // create a Dash Dot Dot Dot\n     * chart.dashStyle([3,1,1,1]);\n     * @param  {Array<Number>} [dashStyle=[]]\n     * @returns {Array<Number>|dc.lineChart}\n     */\n    _chart.dashStyle = function (dashStyle) {\n        if (!arguments.length) {\n            return _dashStyle;\n        }\n        _dashStyle = dashStyle;\n        return _chart;\n    };\n\n    /**\n     * Get or set render area flag. If the flag is set to true then the chart will render the area\n     * beneath each line and the line chart effectively becomes an area chart.\n     * @method renderArea\n     * @memberof dc.lineChart\n     * @instance\n     * @param  {Boolean} [renderArea=false]\n     * @returns {Boolean|dc.lineChart}\n     */\n    _chart.renderArea = function (renderArea) {\n        if (!arguments.length) {\n            return _renderArea;\n        }\n        _renderArea = renderArea;\n        return _chart;\n    };\n\n    function colors (d, i) {\n        return _chart.getColor.call(d, d.values, i);\n    }\n\n    // To keep it backward compatible, this covers multiple cases\n    // See https://github.com/dc-js/dc.js/issues/1376\n    // It will be removed when interpolate and tension are removed.\n    function getCurveFactory () {\n        var curve = null;\n\n        // _curve takes precedence\n        if (_curve) {\n            return _curve;\n        }\n\n        // Approximate the D3v3 behavior\n        if (typeof _interpolate === 'function') {\n            curve = _interpolate;\n        } else {\n            // If _interpolate is string\n            var mapping = {\n                'linear': d3.curveLinear,\n                'linear-closed': d3.curveLinearClosed,\n                'step': d3.curveStep,\n                'step-before': d3.curveStepBefore,\n                'step-after': d3.curveStepAfter,\n                'basis': d3.curveBasis,\n                'basis-open': d3.curveBasisOpen,\n                'basis-closed': d3.curveBasisClosed,\n                'bundle': d3.curveBundle,\n                'cardinal': d3.curveCardinal,\n                'cardinal-open': d3.curveCardinalOpen,\n                'cardinal-closed': d3.curveCardinalClosed,\n                'monotone': d3.curveMonotoneX\n            };\n            curve = mapping[_interpolate];\n        }\n\n        // Default value\n        if (!curve) {\n            curve = d3.curveLinear;\n        }\n\n        if (_tension !== null) {\n            if (typeof curve.tension !== 'function') {\n                dc.logger.warn('tension was specified but the curve/interpolate does not support it.');\n            } else {\n                curve = curve.tension(_tension);\n            }\n        }\n        return curve;\n    }\n\n    function drawLine (layersEnter, layers) {\n        var line = d3.line()\n            .x(function (d) {\n                return _chart.x()(d.x);\n            })\n            .y(function (d) {\n                return _chart.y()(d.y + d.y0);\n            })\n            .curve(getCurveFactory());\n        if (_defined) {\n            line.defined(_defined);\n        }\n\n        var path = layersEnter.append('path')\n            .attr('class', 'line')\n            .attr('stroke', colors);\n        if (_dashStyle) {\n            path.attr('stroke-dasharray', _dashStyle);\n        }\n\n        dc.transition(layers.select('path.line'), _chart.transitionDuration(), _chart.transitionDelay())\n            //.ease('linear')\n            .attr('stroke', colors)\n            .attr('d', function (d) {\n                return safeD(line(d.values));\n            });\n    }\n\n    function drawArea (layersEnter, layers) {\n        if (_renderArea) {\n            var area = d3.area()\n                .x(function (d) {\n                    return _chart.x()(d.x);\n                })\n                .y1(function (d) {\n                    return _chart.y()(d.y + d.y0);\n                })\n                .y0(function (d) {\n                    return _chart.y()(d.y0);\n                })\n                .curve(getCurveFactory());\n            if (_defined) {\n                area.defined(_defined);\n            }\n\n            layersEnter.append('path')\n                .attr('class', 'area')\n                .attr('fill', colors)\n                .attr('d', function (d) {\n                    return safeD(area(d.values));\n                });\n\n            dc.transition(layers.select('path.area'), _chart.transitionDuration(), _chart.transitionDelay())\n                //.ease('linear')\n                .attr('fill', colors)\n                .attr('d', function (d) {\n                    return safeD(area(d.values));\n                });\n        }\n    }\n\n    function safeD (d) {\n        return (!d || d.indexOf('NaN') >= 0) ? 'M0,0' : d;\n    }\n\n    function drawDots (chartBody, layers) {\n        if (_chart.xyTipsOn() === 'always' || (!(_chart.brushOn() || _chart.parentBrushOn()) && _chart.xyTipsOn())) {\n            var tooltipListClass = TOOLTIP_G_CLASS + '-list';\n            var tooltips = chartBody.select('g.' + tooltipListClass);\n\n            if (tooltips.empty()) {\n                tooltips = chartBody.append('g').attr('class', tooltipListClass);\n            }\n\n            layers.each(function (d, layerIndex) {\n                var points = d.values;\n                if (_defined) {\n                    points = points.filter(_defined);\n                }\n\n                var g = tooltips.select('g.' + TOOLTIP_G_CLASS + '._' + layerIndex);\n                if (g.empty()) {\n                    g = tooltips.append('g').attr('class', TOOLTIP_G_CLASS + ' _' + layerIndex);\n                }\n\n                createRefLines(g);\n\n                var dots = g.selectAll('circle.' + DOT_CIRCLE_CLASS)\n                    .data(points, dc.pluck('x'));\n\n                var dotsEnterModify = dots\n                    .enter()\n                        .append('circle')\n                        .attr('class', DOT_CIRCLE_CLASS)\n                        .attr('cx', function (d) {\n                            return dc.utils.safeNumber(_chart.x()(d.x));\n                        })\n                        .attr('cy', function (d) {\n                            return dc.utils.safeNumber(_chart.y()(d.y + d.y0));\n                        })\n                        .attr('r', getDotRadius())\n                        .style('fill-opacity', _dataPointFillOpacity)\n                        .style('stroke-opacity', _dataPointStrokeOpacity)\n                        .attr('fill', _chart.getColor)\n                        .on('mousemove', function () {\n                            var dot = d3.select(this);\n                            showDot(dot);\n                            showRefLines(dot, g);\n                        })\n                        .on('mouseout', function () {\n                            var dot = d3.select(this);\n                            hideDot(dot);\n                            hideRefLines(g);\n                        })\n                    .merge(dots);\n\n                dotsEnterModify.call(renderTitle, d);\n\n                dc.transition(dotsEnterModify, _chart.transitionDuration())\n                    .attr('cx', function (d) {\n                        return dc.utils.safeNumber(_chart.x()(d.x));\n                    })\n                    .attr('cy', function (d) {\n                        return dc.utils.safeNumber(_chart.y()(d.y + d.y0));\n                    })\n                    .attr('fill', _chart.getColor);\n\n                dots.exit().remove();\n            });\n        }\n    }\n\n    _chart.label(function (d) {\n        return dc.utils.printSingleValue(d.y0 + d.y);\n    }, false);\n\n    function drawLabels (layers) {\n        layers.each(function (d, layerIndex) {\n            var layer = d3.select(this);\n            var labels = layer.selectAll('text.lineLabel')\n                .data(d.values, dc.pluck('x'));\n\n            var labelsEnterModify = labels\n                .enter()\n                    .append('text')\n                    .attr('class', 'lineLabel')\n                    .attr('text-anchor', 'middle')\n                .merge(labels);\n\n            dc.transition(labelsEnterModify, _chart.transitionDuration())\n                .attr('x', function (d) {\n                    return dc.utils.safeNumber(_chart.x()(d.x));\n                })\n                .attr('y', function (d) {\n                    var y = _chart.y()(d.y + d.y0) - LABEL_PADDING;\n                    return dc.utils.safeNumber(y);\n                })\n                .text(function (d) {\n                    return _chart.label()(d);\n                });\n\n            dc.transition(labels.exit(), _chart.transitionDuration())\n                .attr('height', 0)\n                .remove();\n        });\n    }\n\n    function createRefLines (g) {\n        var yRefLine = g.select('path.' + Y_AXIS_REF_LINE_CLASS).empty() ?\n            g.append('path').attr('class', Y_AXIS_REF_LINE_CLASS) : g.select('path.' + Y_AXIS_REF_LINE_CLASS);\n        yRefLine.style('display', 'none').attr('stroke-dasharray', '5,5');\n\n        var xRefLine = g.select('path.' + X_AXIS_REF_LINE_CLASS).empty() ?\n            g.append('path').attr('class', X_AXIS_REF_LINE_CLASS) : g.select('path.' + X_AXIS_REF_LINE_CLASS);\n        xRefLine.style('display', 'none').attr('stroke-dasharray', '5,5');\n    }\n\n    function showDot (dot) {\n        dot.style('fill-opacity', 0.8);\n        dot.style('stroke-opacity', 0.8);\n        dot.attr('r', _dotRadius);\n        return dot;\n    }\n\n    function showRefLines (dot, g) {\n        var x = dot.attr('cx');\n        var y = dot.attr('cy');\n        var yAxisX = (_chart._yAxisX() - _chart.margins().left);\n        var yAxisRefPathD = 'M' + yAxisX + ' ' + y + 'L' + (x) + ' ' + (y);\n        var xAxisRefPathD = 'M' + x + ' ' + _chart.yAxisHeight() + 'L' + x + ' ' + y;\n        g.select('path.' + Y_AXIS_REF_LINE_CLASS).style('display', '').attr('d', yAxisRefPathD);\n        g.select('path.' + X_AXIS_REF_LINE_CLASS).style('display', '').attr('d', xAxisRefPathD);\n    }\n\n    function getDotRadius () {\n        return _dataPointRadius || _dotRadius;\n    }\n\n    function hideDot (dot) {\n        dot.style('fill-opacity', _dataPointFillOpacity)\n            .style('stroke-opacity', _dataPointStrokeOpacity)\n            .attr('r', getDotRadius());\n    }\n\n    function hideRefLines (g) {\n        g.select('path.' + Y_AXIS_REF_LINE_CLASS).style('display', 'none');\n        g.select('path.' + X_AXIS_REF_LINE_CLASS).style('display', 'none');\n    }\n\n    function renderTitle (dot, d) {\n        if (_chart.renderTitle()) {\n            dot.select('title').remove();\n            dot.append('title').text(dc.pluck('data', _chart.title(d.name)));\n        }\n    }\n\n    /**\n     * Turn on/off the mouseover behavior of an individual data point which renders a circle and x/y axis\n     * dashed lines back to each respective axis.  This is ignored if the chart\n     * {@link dc.coordinateGridMixin#brushOn brush} is on\n     * @method xyTipsOn\n     * @memberof dc.lineChart\n     * @instance\n     * @param  {Boolean} [xyTipsOn=false]\n     * @returns {Boolean|dc.lineChart}\n     */\n    _chart.xyTipsOn = function (xyTipsOn) {\n        if (!arguments.length) {\n            return _xyTipsOn;\n        }\n        _xyTipsOn = xyTipsOn;\n        return _chart;\n    };\n\n    /**\n     * Get or set the radius (in px) for dots displayed on the data points.\n     * @method dotRadius\n     * @memberof dc.lineChart\n     * @instance\n     * @param  {Number} [dotRadius=5]\n     * @returns {Number|dc.lineChart}\n     */\n    _chart.dotRadius = function (dotRadius) {\n        if (!arguments.length) {\n            return _dotRadius;\n        }\n        _dotRadius = dotRadius;\n        return _chart;\n    };\n\n    /**\n     * Always show individual dots for each datapoint.\n     *\n     * If `options` is falsy, it disables data point rendering. If no `options` are provided, the\n     * current `options` values are instead returned.\n     * @method renderDataPoints\n     * @memberof dc.lineChart\n     * @instance\n     * @example\n     * chart.renderDataPoints({radius: 2, fillOpacity: 0.8, strokeOpacity: 0.8})\n     * @param  {{fillOpacity: Number, strokeOpacity: Number, radius: Number}} [options={fillOpacity: 0.8, strokeOpacity: 0.8, radius: 2}]\n     * @returns {{fillOpacity: Number, strokeOpacity: Number, radius: Number}|dc.lineChart}\n     */\n    _chart.renderDataPoints = function (options) {\n        if (!arguments.length) {\n            return {\n                fillOpacity: _dataPointFillOpacity,\n                strokeOpacity: _dataPointStrokeOpacity,\n                radius: _dataPointRadius\n            };\n        } else if (!options) {\n            _dataPointFillOpacity = DEFAULT_DOT_OPACITY;\n            _dataPointStrokeOpacity = DEFAULT_DOT_OPACITY;\n            _dataPointRadius = null;\n        } else {\n            _dataPointFillOpacity = options.fillOpacity || 0.8;\n            _dataPointStrokeOpacity = options.strokeOpacity || 0.8;\n            _dataPointRadius = options.radius || 2;\n        }\n        return _chart;\n    };\n\n    function colorFilter (color, dashstyle, inv) {\n        return function () {\n            var item = d3.select(this);\n            var match = (item.attr('stroke') === color &&\n                item.attr('stroke-dasharray') === ((dashstyle instanceof Array) ?\n                    dashstyle.join(',') : null)) || item.attr('fill') === color;\n            return inv ? !match : match;\n        };\n    }\n\n    _chart.legendHighlight = function (d) {\n        if (!_chart.isLegendableHidden(d)) {\n            _chart.g().selectAll('path.line, path.area')\n                .classed('highlight', colorFilter(d.color, d.dashstyle))\n                .classed('fadeout', colorFilter(d.color, d.dashstyle, true));\n        }\n    };\n\n    _chart.legendReset = function () {\n        _chart.g().selectAll('path.line, path.area')\n            .classed('highlight', false)\n            .classed('fadeout', false);\n    };\n\n    dc.override(_chart, 'legendables', function () {\n        var legendables = _chart._legendables();\n        if (!_dashStyle) {\n            return legendables;\n        }\n        return legendables.map(function (l) {\n            l.dashstyle = _dashStyle;\n            return l;\n        });\n    });\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The data count widget is a simple widget designed to display the number of records selected by the\n * current filters out of the total number of records in the data set. Once created the data count widget\n * will automatically update the text content of child elements with the following classes:\n *\n * * `.total-count` - total number of records\n * * `.filter-count` - number of records matched by the current filters\n *\n * Note: this widget works best for the specific case of showing the number of records out of a\n * total. If you want a more general-purpose numeric display, please use the\n * {@link dc.numberDisplay} widget instead.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * @class dataCount\n * @memberof dc\n * @mixes dc.baseMixin\n * @example\n * var ndx = crossfilter(data);\n * var all = ndx.groupAll();\n *\n * dc.dataCount('.dc-data-count')\n *     .dimension(ndx)\n *     .group(all);\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.dataCount}\n */\ndc.dataCount = function (parent, chartGroup) {\n    var _formatNumber = d3.format(',d');\n    var _chart = dc.baseMixin({});\n    var _html = {some: '', all: ''};\n\n    /**\n     * Gets or sets an optional object specifying HTML templates to use depending how many items are\n     * selected. The text `%total-count` will replaced with the total number of records, and the text\n     * `%filter-count` will be replaced with the number of selected records.\n     * - all: HTML template to use if all items are selected\n     * - some: HTML template to use if not all items are selected\n     * @method html\n     * @memberof dc.dataCount\n     * @instance\n     * @example\n     * counter.html({\n     *      some: '%filter-count out of %total-count records selected',\n     *      all: 'All records selected. Click on charts to apply filters'\n     * })\n     * @param {{some:String, all: String}} [options]\n     * @returns {{some:String, all: String}|dc.dataCount}\n     */\n    _chart.html = function (options) {\n        if (!arguments.length) {\n            return _html;\n        }\n        if (options.all) {\n            _html.all = options.all;\n        }\n        if (options.some) {\n            _html.some = options.some;\n        }\n        return _chart;\n    };\n\n    /**\n     * Gets or sets an optional function to format the filter count and total count.\n     * @method formatNumber\n     * @memberof dc.dataCount\n     * @instance\n     * @see {@link https://github.com/d3/d3-format/blob/master/README.md#format d3.format}\n     * @example\n     * counter.formatNumber(d3.format('.2g'))\n     * @param {Function} [formatter=d3.format('.2g')]\n     * @returns {Function|dc.dataCount}\n     */\n    _chart.formatNumber = function (formatter) {\n        if (!arguments.length) {\n            return _formatNumber;\n        }\n        _formatNumber = formatter;\n        return _chart;\n    };\n\n    _chart._doRender = function () {\n        var tot = _chart.dimension().size(),\n            val = _chart.group().value();\n        var all = _formatNumber(tot);\n        var selected = _formatNumber(val);\n\n        if ((tot === val) && (_html.all !== '')) {\n            _chart.root().html(_html.all.replace('%total-count', all).replace('%filter-count', selected));\n        } else if (_html.some !== '') {\n            _chart.root().html(_html.some.replace('%total-count', all).replace('%filter-count', selected));\n        } else {\n            _chart.selectAll('.total-count').text(all);\n            _chart.selectAll('.filter-count').text(selected);\n        }\n        return _chart;\n    };\n\n    _chart._doRedraw = function () {\n        return _chart._doRender();\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The data table is a simple widget designed to list crossfilter focused data set (rows being\n * filtered) in a good old tabular fashion.\n *\n * Note: Unlike other charts, the data table (and data grid chart) use the {@link dc.dataTable#group group} attribute as a\n * keying function for {@link https://github.com/d3/d3-collection/blob/master/README.md#nest nesting} the data\n * together in groups.  Do not pass in a crossfilter group as this will not work.\n *\n * Another interesting feature of the data table is that you can pass a crossfilter group to the `dimension`, as\n * long as you specify the {@link dc.dataTable#order order} as `d3.descending`, since the data\n * table will use `dimension.top()` to fetch the data in that case, and the method is equally\n * supported on the crossfilter group as the crossfilter dimension.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * - {@link http://dc-js.github.io/dc.js/examples/table-on-aggregated-data.html dataTable on a crossfilter group}\n * ({@link https://github.com/dc-js/dc.js/blob/develop/web/examples/table-on-aggregated-data.html source})\n * @class dataTable\n * @memberof dc\n * @mixes dc.baseMixin\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.dataTable}\n */\ndc.dataTable = function (parent, chartGroup) {\n    var LABEL_CSS_CLASS = 'dc-table-label';\n    var ROW_CSS_CLASS = 'dc-table-row';\n    var COLUMN_CSS_CLASS = 'dc-table-column';\n    var GROUP_CSS_CLASS = 'dc-table-group';\n    var HEAD_CSS_CLASS = 'dc-table-head';\n\n    var _chart = dc.baseMixin({});\n\n    var _size = 25;\n    var _columns = [];\n    var _sortBy = function (d) {\n        return d;\n    };\n    var _order = d3.ascending;\n    var _beginSlice = 0;\n    var _endSlice;\n    var _showGroups = true;\n\n    _chart._doRender = function () {\n        _chart.selectAll('tbody').remove();\n\n        renderRows(renderGroups());\n\n        return _chart;\n    };\n\n    _chart._doColumnValueFormat = function (v, d) {\n        return ((typeof v === 'function') ?\n                v(d) :                          // v as function\n                ((typeof v === 'string') ?\n                 d[v] :                         // v is field name string\n                 v.format(d)                        // v is Object, use fn (element 2)\n                )\n               );\n    };\n\n    _chart._doColumnHeaderFormat = function (d) {\n        // if 'function', convert to string representation\n        // show a string capitalized\n        // if an object then display its label string as-is.\n        return (typeof d === 'function') ?\n                _chart._doColumnHeaderFnToString(d) :\n                ((typeof d === 'string') ?\n                 _chart._doColumnHeaderCapitalize(d) : String(d.label));\n    };\n\n    _chart._doColumnHeaderCapitalize = function (s) {\n        // capitalize\n        return s.charAt(0).toUpperCase() + s.slice(1);\n    };\n\n    _chart._doColumnHeaderFnToString = function (f) {\n        // columnString(f) {\n        var s = String(f);\n        var i1 = s.indexOf('return ');\n        if (i1 >= 0) {\n            var i2 = s.lastIndexOf(';');\n            if (i2 >= 0) {\n                s = s.substring(i1 + 7, i2);\n                var i3 = s.indexOf('numberFormat');\n                if (i3 >= 0) {\n                    s = s.replace('numberFormat', '');\n                }\n            }\n        }\n        return s;\n    };\n\n    function renderGroups () {\n        // The 'original' example uses all 'functions'.\n        // If all 'functions' are used, then don't remove/add a header, and leave\n        // the html alone. This preserves the functionality of earlier releases.\n        // A 2nd option is a string representing a field in the data.\n        // A third option is to supply an Object such as an array of 'information', and\n        // supply your own _doColumnHeaderFormat and _doColumnValueFormat functions to\n        // create what you need.\n        var bAllFunctions = true;\n        _columns.forEach(function (f) {\n            bAllFunctions = bAllFunctions & (typeof f === 'function');\n        });\n\n        if (!bAllFunctions) {\n            // ensure one thead\n            var thead = _chart.selectAll('thead').data([0]);\n            thead.exit().remove();\n            thead = thead.enter()\n                    .append('thead')\n                .merge(thead);\n\n            // with one tr\n            var headrow = thead.selectAll('tr').data([0]);\n            headrow.exit().remove();\n            headrow = headrow.enter()\n                    .append('tr')\n                .merge(headrow);\n\n            // with a th for each column\n            var headcols = headrow.selectAll('th')\n                .data(_columns);\n            headcols.exit().remove();\n            headcols.enter().append('th')\n                .merge(headcols)\n                    .attr('class', HEAD_CSS_CLASS)\n                    .html(function (d) {\n                        return (_chart._doColumnHeaderFormat(d));\n                    });\n        }\n\n        var groups = _chart.root().selectAll('tbody')\n            .data(nestEntries(), function (d) {\n                return _chart.keyAccessor()(d);\n            });\n\n        var rowGroup = groups\n            .enter()\n            .append('tbody');\n\n        if (_showGroups === true) {\n            rowGroup\n                .append('tr')\n                .attr('class', GROUP_CSS_CLASS)\n                    .append('td')\n                    .attr('class', LABEL_CSS_CLASS)\n                    .attr('colspan', _columns.length)\n                    .html(function (d) {\n                        return _chart.keyAccessor()(d);\n                    });\n        }\n\n        groups.exit().remove();\n\n        return rowGroup;\n    }\n\n    function nestEntries () {\n        var entries;\n        if (_order === d3.ascending) {\n            entries = _chart.dimension().bottom(_size);\n        } else {\n            entries = _chart.dimension().top(_size);\n        }\n\n        return d3.nest()\n            .key(_chart.group())\n            .sortKeys(_order)\n            .entries(entries.sort(function (a, b) {\n                return _order(_sortBy(a), _sortBy(b));\n            }).slice(_beginSlice, _endSlice));\n    }\n\n    function renderRows (groups) {\n        var rows = groups.order()\n            .selectAll('tr.' + ROW_CSS_CLASS)\n            .data(function (d) {\n                return d.values;\n            });\n\n        var rowEnter = rows.enter()\n            .append('tr')\n            .attr('class', ROW_CSS_CLASS);\n\n        _columns.forEach(function (v, i) {\n            rowEnter.append('td')\n                .attr('class', COLUMN_CSS_CLASS + ' _' + i)\n                .html(function (d) {\n                    return _chart._doColumnValueFormat(v, d);\n                });\n        });\n\n        rows.exit().remove();\n\n        return rows;\n    }\n\n    _chart._doRedraw = function () {\n        return _chart._doRender();\n    };\n\n    /**\n     * Get or set the group function for the data table. The group function takes a data row and\n     * returns the key to specify to {@link https://github.com/d3/d3-collection/blob/master/README.md#nest d3.nest}\n     * to split rows into groups.\n     *\n     * Do not pass in a crossfilter group as this will not work.\n     * @method group\n     * @memberof dc.dataTable\n     * @instance\n     * @example\n     * // group rows by the value of their field\n     * chart\n     *     .group(function(d) { return d.field; })\n     * @param {Function} groupFunction Function taking a row of data and returning the nest key.\n     * @returns {Function|dc.dataTable}\n     */\n\n    /**\n     * Get or set the table size which determines the number of rows displayed by the widget.\n     * @method size\n     * @memberof dc.dataTable\n     * @instance\n     * @param {Number} [size=25]\n     * @returns {Number|dc.dataTable}\n     */\n    _chart.size = function (size) {\n        if (!arguments.length) {\n            return _size;\n        }\n        _size = size;\n        return _chart;\n    };\n\n    /**\n     * Get or set the index of the beginning slice which determines which entries get displayed\n     * by the widget. Useful when implementing pagination.\n     *\n     * Note: the sortBy function will determine how the rows are ordered for pagination purposes.\n\n     * See the {@link http://dc-js.github.io/dc.js/examples/table-pagination.html table pagination example}\n     * to see how to implement the pagination user interface using `beginSlice` and `endSlice`.\n     * @method beginSlice\n     * @memberof dc.dataTable\n     * @instance\n     * @param {Number} [beginSlice=0]\n     * @returns {Number|dc.dataTable}\n     */\n    _chart.beginSlice = function (beginSlice) {\n        if (!arguments.length) {\n            return _beginSlice;\n        }\n        _beginSlice = beginSlice;\n        return _chart;\n    };\n\n    /**\n     * Get or set the index of the end slice which determines which entries get displayed by the\n     * widget. Useful when implementing pagination. See {@link dc.dataTable#beginSlice `beginSlice`} for more information.\n     * @method endSlice\n     * @memberof dc.dataTable\n     * @instance\n     * @param {Number|undefined} [endSlice=undefined]\n     * @returns {Number|dc.dataTable}\n     */\n    _chart.endSlice = function (endSlice) {\n        if (!arguments.length) {\n            return _endSlice;\n        }\n        _endSlice = endSlice;\n        return _chart;\n    };\n\n    /**\n     * Get or set column functions. The data table widget supports several methods of specifying the\n     * columns to display.\n     *\n     * The original method uses an array of functions to generate dynamic columns. Column functions\n     * are simple javascript functions with only one input argument `d` which represents a row in\n     * the data set. The return value of these functions will be used to generate the content for\n     * each cell. However, this method requires the HTML for the table to have a fixed set of column\n     * headers.\n     *\n     * <pre><code>chart.columns([\n     *     function(d) { return d.date; },\n     *     function(d) { return d.open; },\n     *     function(d) { return d.close; },\n     *     function(d) { return numberFormat(d.close - d.open); },\n     *     function(d) { return d.volume; }\n     * ]);\n     * </code></pre>\n     *\n     * In the second method, you can list the columns to read from the data without specifying it as\n     * a function, except where necessary (ie, computed columns).  Note the data element name is\n     * capitalized when displayed in the table header. You can also mix in functions as necessary,\n     * using the third `{label, format}` form, as shown below.\n     *\n     * <pre><code>chart.columns([\n     *     \"date\",    // d[\"date\"], ie, a field accessor; capitalized automatically\n     *     \"open\",    // ...\n     *     \"close\",   // ...\n     *     {\n     *         label: \"Change\",\n     *         format: function (d) {\n     *             return numberFormat(d.close - d.open);\n     *         }\n     *     },\n     *     \"volume\"   // d[\"volume\"], ie, a field accessor; capitalized automatically\n     * ]);\n     * </code></pre>\n     *\n     * In the third example, we specify all fields using the `{label, format}` method:\n     * <pre><code>chart.columns([\n     *     {\n     *         label: \"Date\",\n     *         format: function (d) { return d.date; }\n     *     },\n     *     {\n     *         label: \"Open\",\n     *         format: function (d) { return numberFormat(d.open); }\n     *     },\n     *     {\n     *         label: \"Close\",\n     *         format: function (d) { return numberFormat(d.close); }\n     *     },\n     *     {\n     *         label: \"Change\",\n     *         format: function (d) { return numberFormat(d.close - d.open); }\n     *     },\n     *     {\n     *         label: \"Volume\",\n     *         format: function (d) { return d.volume; }\n     *     }\n     * ]);\n     * </code></pre>\n     *\n     * You may wish to override the dataTable functions `_doColumnHeaderCapitalize` and\n     * `_doColumnHeaderFnToString`, which are used internally to translate the column information or\n     * function into a displayed header. The first one is used on the \"string\" column specifier; the\n     * second is used to transform a stringified function into something displayable. For the Stock\n     * example, the function for Change becomes the table header **d.close - d.open**.\n     *\n     * Finally, you can even specify a completely different form of column definition. To do this,\n     * override `_chart._doColumnHeaderFormat` and `_chart._doColumnValueFormat` Be aware that\n     * fields without numberFormat specification will be displayed just as they are stored in the\n     * data, unformatted.\n     * @method columns\n     * @memberof dc.dataTable\n     * @instance\n     * @param {Array<Function>} [columns=[]]\n     * @returns {Array<Function>}|dc.dataTable}\n     */\n    _chart.columns = function (columns) {\n        if (!arguments.length) {\n            return _columns;\n        }\n        _columns = columns;\n        return _chart;\n    };\n\n    /**\n     * Get or set sort-by function. This function works as a value accessor at row level and returns a\n     * particular field to be sorted by.\n     * @method sortBy\n     * @memberof dc.dataTable\n     * @instance\n     * @example\n     * chart.sortBy(function(d) {\n     *     return d.date;\n     * });\n     * @param {Function} [sortBy=identity function]\n     * @returns {Function|dc.dataTable}\n     */\n    _chart.sortBy = function (sortBy) {\n        if (!arguments.length) {\n            return _sortBy;\n        }\n        _sortBy = sortBy;\n        return _chart;\n    };\n\n    /**\n     * Get or set sort order. If the order is `d3.ascending`, the data table will use\n     * `dimension().bottom()` to fetch the data; otherwise it will use `dimension().top()`\n     * @method order\n     * @memberof dc.dataTable\n     * @instance\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending}\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending}\n     * @example\n     * chart.order(d3.descending);\n     * @param {Function} [order=d3.ascending]\n     * @returns {Function|dc.dataTable}\n     */\n    _chart.order = function (order) {\n        if (!arguments.length) {\n            return _order;\n        }\n        _order = order;\n        return _chart;\n    };\n\n    /**\n     * Get or set if group rows will be shown. The dataTable {@link dc.dataTable#group group}\n     * function must be specified even if groups are not shown.\n     * @method showGroups\n     * @memberof dc.dataTable\n     * @instance\n     * @example\n     * chart\n     *     .group([value], [name])\n     *     .showGroups(true|false);\n     * @param {Boolean} [showGroups=true]\n     * @returns {Boolean|dc.dataTable}\n     */\n    _chart.showGroups = function (showGroups) {\n        if (!arguments.length) {\n            return _showGroups;\n        }\n        _showGroups = showGroups;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Data grid is a simple widget designed to list the filtered records, providing\n * a simple way to define how the items are displayed.\n *\n * Note: Unlike other charts, the data grid chart (and data table) use the {@link dc.dataGrid#group group} attribute as a keying function\n * for {@link https://github.com/d3/d3-collection/blob/master/README.md#nest nesting} the data together in groups.\n * Do not pass in a crossfilter group as this will not work.\n *\n * Examples:\n * - {@link http://europarl.me/dc.js/web/ep/index.html List of members of the european parliament}\n * @class dataGrid\n * @memberof dc\n * @mixes dc.baseMixin\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.dataGrid}\n */\ndc.dataGrid = function (parent, chartGroup) {\n    var LABEL_CSS_CLASS = 'dc-grid-label';\n    var ITEM_CSS_CLASS = 'dc-grid-item';\n    var GROUP_CSS_CLASS = 'dc-grid-group';\n    var GRID_CSS_CLASS = 'dc-grid-top';\n\n    var _chart = dc.baseMixin({});\n\n    var _size = 999; // shouldn't be needed, but you might\n    var _html = function (d) { return 'you need to provide an html() handling param:  ' + JSON.stringify(d); };\n    var _sortBy = function (d) {\n        return d;\n    };\n    var _order = d3.ascending;\n    var _beginSlice = 0, _endSlice;\n\n    var _htmlGroup = function (d) {\n        return '<div class=\\'' + GROUP_CSS_CLASS + '\\'><h1 class=\\'' + LABEL_CSS_CLASS + '\\'>' +\n            _chart.keyAccessor()(d) + '</h1></div>';\n    };\n\n    _chart._doRender = function () {\n        _chart.selectAll('div.' + GRID_CSS_CLASS).remove();\n\n        renderItems(renderGroups());\n\n        return _chart;\n    };\n\n    function renderGroups () {\n        var groups = _chart.root().selectAll('div.' + GRID_CSS_CLASS)\n                .data(nestEntries(), function (d) {\n                    return _chart.keyAccessor()(d);\n                });\n\n        var itemGroup = groups\n                .enter()\n                .append('div')\n                .attr('class', GRID_CSS_CLASS);\n\n        if (_htmlGroup) {\n            itemGroup\n                .html(function (d) {\n                    return _htmlGroup(d);\n                });\n        }\n\n        groups.exit().remove();\n        return itemGroup;\n    }\n\n    function nestEntries () {\n        var entries = _chart.dimension().top(_size);\n\n        return d3.nest()\n            .key(_chart.group())\n            .sortKeys(_order)\n            .entries(entries.sort(function (a, b) {\n                return _order(_sortBy(a), _sortBy(b));\n            }).slice(_beginSlice, _endSlice));\n    }\n\n    function renderItems (groups) {\n        var items = groups.order()\n                .selectAll('div.' + ITEM_CSS_CLASS)\n                .data(function (d) {\n                    return d.values;\n                });\n\n        items.exit().remove();\n\n        items = items\n            .enter()\n                .append('div')\n                .attr('class', ITEM_CSS_CLASS)\n                .html(function (d) {\n                    return _html(d);\n                })\n            .merge(items);\n\n        return items;\n    }\n\n    _chart._doRedraw = function () {\n        return _chart._doRender();\n    };\n\n    /**\n     * Get or set the group function for the data grid. The group function takes a data row and\n     * returns the key to specify to {@link https://github.com/d3/d3-collection/blob/master/README.md#nest d3.nest}\n     * to split rows into groups.\n     *\n     * Do not pass in a crossfilter group as this will not work.\n     * @method group\n     * @memberof dc.dataGrid\n     * @instance\n     * @example\n     * // group rows by the value of their field\n     * chart\n     *     .group(function(d) { return d.field; })\n     * @param {Function} groupFunction Function taking a row of data and returning the nest key.\n     * @returns {Function|dc.dataTable}\n     */\n\n    /**\n     * Get or set the index of the beginning slice which determines which entries get displayed by the widget.\n     * Useful when implementing pagination.\n     * @method beginSlice\n     * @memberof dc.dataGrid\n     * @instance\n     * @param {Number} [beginSlice=0]\n     * @returns {Number|dc.dataGrid}\n     */\n    _chart.beginSlice = function (beginSlice) {\n        if (!arguments.length) {\n            return _beginSlice;\n        }\n        _beginSlice = beginSlice;\n        return _chart;\n    };\n\n    /**\n     * Get or set the index of the end slice which determines which entries get displayed by the widget.\n     * Useful when implementing pagination.\n     * @method endSlice\n     * @memberof dc.dataGrid\n     * @instance\n     * @param {Number} [endSlice]\n     * @returns {Number|dc.dataGrid}\n     */\n    _chart.endSlice = function (endSlice) {\n        if (!arguments.length) {\n            return _endSlice;\n        }\n        _endSlice = endSlice;\n        return _chart;\n    };\n\n    /**\n     * Get or set the grid size which determines the number of items displayed by the widget.\n     * @method size\n     * @memberof dc.dataGrid\n     * @instance\n     * @param {Number} [size=999]\n     * @returns {Number|dc.dataGrid}\n     */\n    _chart.size = function (size) {\n        if (!arguments.length) {\n            return _size;\n        }\n        _size = size;\n        return _chart;\n    };\n\n    /**\n     * Get or set the function that formats an item. The data grid widget uses a\n     * function to generate dynamic html. Use your favourite templating engine or\n     * generate the string directly.\n     * @method html\n     * @memberof dc.dataGrid\n     * @instance\n     * @example\n     * chart.html(function (d) { return '<div class='item '+data.exampleCategory+''>'+data.exampleString+'</div>';});\n     * @param {Function} [html]\n     * @returns {Function|dc.dataGrid}\n     */\n    _chart.html = function (html) {\n        if (!arguments.length) {\n            return _html;\n        }\n        _html = html;\n        return _chart;\n    };\n\n    /**\n     * Get or set the function that formats a group label.\n     * @method htmlGroup\n     * @memberof dc.dataGrid\n     * @instance\n     * @example\n     * chart.htmlGroup (function (d) { return '<h2>'.d.key . 'with ' . d.values.length .' items</h2>'});\n     * @param {Function} [htmlGroup]\n     * @returns {Function|dc.dataGrid}\n     */\n    _chart.htmlGroup = function (htmlGroup) {\n        if (!arguments.length) {\n            return _htmlGroup;\n        }\n        _htmlGroup = htmlGroup;\n        return _chart;\n    };\n\n    /**\n     * Get or set sort-by function. This function works as a value accessor at the item\n     * level and returns a particular field to be sorted.\n     * @method sortBy\n     * @memberof dc.dataGrid\n     * @instance\n     * @example\n     * chart.sortBy(function(d) {\n     *     return d.date;\n     * });\n     * @param {Function} [sortByFunction]\n     * @returns {Function|dc.dataGrid}\n     */\n    _chart.sortBy = function (sortByFunction) {\n        if (!arguments.length) {\n            return _sortBy;\n        }\n        _sortBy = sortByFunction;\n        return _chart;\n    };\n\n    /**\n     * Get or set sort the order function.\n     * @method order\n     * @memberof dc.dataGrid\n     * @instance\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending}\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending}\n     * @example\n     * chart.order(d3.descending);\n     * @param {Function} [order=d3.ascending]\n     * @returns {Function|dc.dataGrid}\n     */\n    _chart.order = function (order) {\n        if (!arguments.length) {\n            return _order;\n        }\n        _order = order;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * A concrete implementation of a general purpose bubble chart that allows data visualization using the\n * following dimensions:\n * - x axis position\n * - y axis position\n * - bubble radius\n * - color\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * - {@link http://dc-js.github.com/dc.js/vc/index.html US Venture Capital Landscape 2011}\n * @class bubbleChart\n * @memberof dc\n * @mixes dc.bubbleMixin\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a bubble chart under #chart-container1 element using the default global chart group\n * var bubbleChart1 = dc.bubbleChart('#chart-container1');\n * // create a bubble chart under #chart-container2 element using chart group A\n * var bubbleChart2 = dc.bubbleChart('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.bubbleChart}\n */\ndc.bubbleChart = function (parent, chartGroup) {\n    var _chart = dc.bubbleMixin(dc.coordinateGridMixin({}));\n\n    _chart.transitionDuration(750);\n\n    _chart.transitionDelay(0);\n\n    var bubbleLocator = function (d) {\n        return 'translate(' + (bubbleX(d)) + ',' + (bubbleY(d)) + ')';\n    };\n\n    _chart.plotData = function () {\n        _chart.calculateRadiusDomain();\n        _chart.r().range([_chart.MIN_RADIUS, _chart.xAxisLength() * _chart.maxBubbleRelativeSize()]);\n\n        var data = _chart.data();\n        var bubbleG = _chart.chartBodyG().selectAll('g.' + _chart.BUBBLE_NODE_CLASS)\n                .data(data, function (d) { return d.key; });\n        if (_chart.sortBubbleSize()) {\n            // update dom order based on sort\n            bubbleG.order();\n        }\n\n        bubbleG = renderNodes(bubbleG);\n\n        updateNodes(bubbleG);\n\n        removeNodes(bubbleG);\n\n        _chart.fadeDeselectedArea(_chart.filter());\n    };\n\n    function renderNodes (bubbleG) {\n        var bubbleGEnter = bubbleG.enter().append('g');\n\n        bubbleGEnter\n            .attr('class', _chart.BUBBLE_NODE_CLASS)\n            .attr('transform', bubbleLocator)\n            .append('circle').attr('class', function (d, i) {\n                return _chart.BUBBLE_CLASS + ' _' + i;\n            })\n            .on('click', _chart.onClick)\n            .attr('fill', _chart.getColor)\n            .attr('r', 0);\n\n        bubbleG = bubbleGEnter.merge(bubbleG);\n\n        dc.transition(bubbleG, _chart.transitionDuration(), _chart.transitionDelay())\n            .select('circle.' + _chart.BUBBLE_CLASS)\n            .attr('r', function (d) {\n                return _chart.bubbleR(d);\n            })\n            .attr('opacity', function (d) {\n                return (_chart.bubbleR(d) > 0) ? 1 : 0;\n            });\n\n        _chart._doRenderLabel(bubbleGEnter);\n\n        _chart._doRenderTitles(bubbleGEnter);\n\n        return bubbleG;\n    }\n\n    function updateNodes (bubbleG) {\n        dc.transition(bubbleG, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', bubbleLocator)\n            .select('circle.' + _chart.BUBBLE_CLASS)\n            .attr('fill', _chart.getColor)\n            .attr('r', function (d) {\n                return _chart.bubbleR(d);\n            })\n            .attr('opacity', function (d) {\n                return (_chart.bubbleR(d) > 0) ? 1 : 0;\n            });\n\n        _chart.doUpdateLabels(bubbleG);\n        _chart.doUpdateTitles(bubbleG);\n    }\n\n    function removeNodes (bubbleG) {\n        bubbleG.exit().remove();\n    }\n\n    function bubbleX (d) {\n        var x = _chart.x()(_chart.keyAccessor()(d));\n        if (isNaN(x) || !isFinite(x)) {\n            x = 0;\n        }\n        return x;\n    }\n\n    function bubbleY (d) {\n        var y = _chart.y()(_chart.valueAccessor()(d));\n        if (isNaN(y) || !isFinite(y)) {\n            y = 0;\n        }\n        return y;\n    }\n\n    _chart.renderBrush = function () {\n        // override default x axis brush from parent chart\n    };\n\n    _chart.redrawBrush = function (brushSelection, doTransition) {\n        // override default x axis brush from parent chart\n        _chart.fadeDeselectedArea(brushSelection);\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Composite charts are a special kind of chart that render multiple charts on the same Coordinate\n * Grid. You can overlay (compose) different bar/line/area charts in a single composite chart to\n * achieve some quite flexible charting effects.\n * @class compositeChart\n * @memberof dc\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a composite chart under #chart-container1 element using the default global chart group\n * var compositeChart1 = dc.compositeChart('#chart-container1');\n * // create a composite chart under #chart-container2 element using chart group A\n * var compositeChart2 = dc.compositeChart('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.compositeChart}\n */\ndc.compositeChart = function (parent, chartGroup) {\n\n    var SUB_CHART_CLASS = 'sub';\n    var DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING = 12;\n\n    var _chart = dc.coordinateGridMixin({});\n    var _children = [];\n\n    var _childOptions = {};\n\n    var _shareColors = false,\n        _shareTitle = true,\n        _alignYAxes = false;\n\n    var _rightYAxis = d3.axisRight(),\n        _rightYAxisLabel = 0,\n        _rightYAxisLabelPadding = DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING,\n        _rightY,\n        _rightAxisGridLines = false;\n\n    _chart._mandatoryAttributes([]);\n    _chart.transitionDuration(500);\n    _chart.transitionDelay(0);\n\n    dc.override(_chart, '_generateG', function () {\n        var g = this.__generateG();\n\n        for (var i = 0; i < _children.length; ++i) {\n            var child = _children[i];\n\n            generateChildG(child, i);\n\n            if (!child.dimension()) {\n                child.dimension(_chart.dimension());\n            }\n            if (!child.group()) {\n                child.group(_chart.group());\n            }\n\n            child.chartGroup(_chart.chartGroup());\n            child.svg(_chart.svg());\n            child.xUnits(_chart.xUnits());\n            child.transitionDuration(_chart.transitionDuration(), _chart.transitionDelay());\n            child.parentBrushOn(_chart.brushOn());\n            child.brushOn(false);\n            child.renderTitle(_chart.renderTitle());\n            child.elasticX(_chart.elasticX());\n        }\n\n        return g;\n    });\n\n    _chart.applyBrushSelection = function (rangedFilter) {\n        _chart.replaceFilter(rangedFilter);\n        for (var i = 0; i < _children.length; ++i) {\n            _children[i].replaceFilter(rangedFilter);\n        }\n        _chart.redrawGroup();\n    };\n\n    _chart._prepareYAxis = function () {\n        var left = (leftYAxisChildren().length !== 0);\n        var right = (rightYAxisChildren().length !== 0);\n        var ranges = calculateYAxisRanges(left, right);\n\n        if (left) { prepareLeftYAxis(ranges); }\n        if (right) { prepareRightYAxis(ranges); }\n\n        if (leftYAxisChildren().length > 0 && !_rightAxisGridLines) {\n            _chart._renderHorizontalGridLinesForAxis(_chart.g(), _chart.y(), _chart.yAxis());\n        } else if (rightYAxisChildren().length > 0) {\n            _chart._renderHorizontalGridLinesForAxis(_chart.g(), _rightY, _rightYAxis);\n        }\n    };\n\n    _chart.renderYAxis = function () {\n        if (leftYAxisChildren().length !== 0) {\n            _chart.renderYAxisAt('y', _chart.yAxis(), _chart.margins().left);\n            _chart.renderYAxisLabel('y', _chart.yAxisLabel(), -90);\n        }\n\n        if (rightYAxisChildren().length !== 0) {\n            _chart.renderYAxisAt('yr', _chart.rightYAxis(), _chart.width() - _chart.margins().right);\n            _chart.renderYAxisLabel('yr', _chart.rightYAxisLabel(), 90, _chart.width() - _rightYAxisLabelPadding);\n        }\n    };\n\n    function calculateYAxisRanges (left, right) {\n        var lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax;\n        var ranges;\n\n        if (left) {\n            lyAxisMin = yAxisMin();\n            lyAxisMax = yAxisMax();\n        }\n\n        if (right) {\n            ryAxisMin = rightYAxisMin();\n            ryAxisMax = rightYAxisMax();\n        }\n\n        if (_chart.alignYAxes() && left && right) {\n            ranges = alignYAxisRanges(lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax);\n        }\n\n        return ranges || {\n            lyAxisMin: lyAxisMin,\n            lyAxisMax: lyAxisMax,\n            ryAxisMin: ryAxisMin,\n            ryAxisMax: ryAxisMax\n        };\n    }\n\n    function alignYAxisRanges (lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax) {\n        // since the two series will share a zero, each Y is just a multiple\n        // of the other. and the ratio should be the ratio of the ranges of the\n        // input data, so that they come out the same height. so we just min/max\n\n        // note: both ranges already include zero due to the stack mixin (#667)\n        // if #667 changes, we can reconsider whether we want data height or\n        // height from zero to be equal. and it will be possible for the axes\n        // to be aligned but not visible.\n        var extentRatio = (ryAxisMax - ryAxisMin) / (lyAxisMax - lyAxisMin);\n\n        return {\n            lyAxisMin: Math.min(lyAxisMin, ryAxisMin / extentRatio),\n            lyAxisMax: Math.max(lyAxisMax, ryAxisMax / extentRatio),\n            ryAxisMin: Math.min(ryAxisMin, lyAxisMin * extentRatio),\n            ryAxisMax: Math.max(ryAxisMax, lyAxisMax * extentRatio)\n        };\n    }\n\n    function prepareRightYAxis (ranges) {\n        var needDomain = _chart.rightY() === undefined || _chart.elasticY(),\n            needRange = needDomain || _chart.resizing();\n        if (_chart.rightY() === undefined) {\n            _chart.rightY(d3.scaleLinear());\n        }\n        if (needDomain) {\n            _chart.rightY().domain([ranges.ryAxisMin, ranges.ryAxisMax]);\n        }\n        if (needRange) {\n            _chart.rightY().rangeRound([_chart.yAxisHeight(), 0]);\n        }\n\n        _chart.rightY().range([_chart.yAxisHeight(), 0]);\n        _chart.rightYAxis(_chart.rightYAxis().scale(_chart.rightY()));\n\n        // In D3v4 create a RightAxis\n        // _chart.rightYAxis().orient('right');\n    }\n\n    function prepareLeftYAxis (ranges) {\n        var needDomain = _chart.y() === undefined || _chart.elasticY(),\n            needRange = needDomain || _chart.resizing();\n        if (_chart.y() === undefined) {\n            _chart.y(d3.scaleLinear());\n        }\n        if (needDomain) {\n            _chart.y().domain([ranges.lyAxisMin, ranges.lyAxisMax]);\n        }\n        if (needRange) {\n            _chart.y().rangeRound([_chart.yAxisHeight(), 0]);\n        }\n\n        _chart.y().range([_chart.yAxisHeight(), 0]);\n        _chart.yAxis(_chart.yAxis().scale(_chart.y()));\n\n        // In D3v4 create a LeftAxis\n        // _chart.yAxis().orient('left');\n    }\n\n    function generateChildG (child, i) {\n        child._generateG(_chart.g());\n        child.g().attr('class', SUB_CHART_CLASS + ' _' + i);\n    }\n\n    _chart.plotData = function () {\n        for (var i = 0; i < _children.length; ++i) {\n            var child = _children[i];\n\n            if (!child.g()) {\n                generateChildG(child, i);\n            }\n\n            if (_shareColors) {\n                child.colors(_chart.colors());\n            }\n\n            child.x(_chart.x());\n\n            child.xAxis(_chart.xAxis());\n\n            if (child.useRightYAxis()) {\n                child.y(_chart.rightY());\n                child.yAxis(_chart.rightYAxis());\n            } else {\n                child.y(_chart.y());\n                child.yAxis(_chart.yAxis());\n            }\n\n            child.plotData();\n\n            child._activateRenderlets();\n        }\n    };\n\n    /**\n     * Get or set whether to draw gridlines from the right y axis.  Drawing from the left y axis is the\n     * default behavior. This option is only respected when subcharts with both left and right y-axes\n     * are present.\n     * @method useRightAxisGridLines\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {Boolean} [useRightAxisGridLines=false]\n     * @returns {Boolean|dc.compositeChart}\n     */\n    _chart.useRightAxisGridLines = function (useRightAxisGridLines) {\n        if (!arguments) {\n            return _rightAxisGridLines;\n        }\n\n        _rightAxisGridLines = useRightAxisGridLines;\n        return _chart;\n    };\n\n    /**\n     * Get or set chart-specific options for all child charts. This is equivalent to calling\n     * {@link dc.baseMixin#options .options} on each child chart.\n     * @method childOptions\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {Object} [childOptions]\n     * @returns {Object|dc.compositeChart}\n     */\n    _chart.childOptions = function (childOptions) {\n        if (!arguments.length) {\n            return _childOptions;\n        }\n        _childOptions = childOptions;\n        _children.forEach(function (child) {\n            child.options(_childOptions);\n        });\n        return _chart;\n    };\n\n    _chart.fadeDeselectedArea = function (brushSelection) {\n        if (_chart.brushOn()) {\n            for (var i = 0; i < _children.length; ++i) {\n                var child = _children[i];\n                child.fadeDeselectedArea(brushSelection);\n            }\n        }\n    };\n\n    /**\n     * Set or get the right y axis label.\n     * @method rightYAxisLabel\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {String} [rightYAxisLabel]\n     * @param {Number} [padding]\n     * @returns {String|dc.compositeChart}\n     */\n    _chart.rightYAxisLabel = function (rightYAxisLabel, padding) {\n        if (!arguments.length) {\n            return _rightYAxisLabel;\n        }\n        _rightYAxisLabel = rightYAxisLabel;\n        _chart.margins().right -= _rightYAxisLabelPadding;\n        _rightYAxisLabelPadding = (padding === undefined) ? DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING : padding;\n        _chart.margins().right += _rightYAxisLabelPadding;\n        return _chart;\n    };\n\n    /**\n     * Combine the given charts into one single composite coordinate grid chart.\n     * @method compose\n     * @memberof dc.compositeChart\n     * @instance\n     * @example\n     * moveChart.compose([\n     *     // when creating sub-chart you need to pass in the parent chart\n     *     dc.lineChart(moveChart)\n     *         .group(indexAvgByMonthGroup) // if group is missing then parent's group will be used\n     *         .valueAccessor(function (d){return d.value.avg;})\n     *         // most of the normal functions will continue to work in a composed chart\n     *         .renderArea(true)\n     *         .stack(monthlyMoveGroup, function (d){return d.value;})\n     *         .title(function (d){\n     *             var value = d.value.avg?d.value.avg:d.value;\n     *             if(isNaN(value)) value = 0;\n     *             return dateFormat(d.key) + '\\n' + numberFormat(value);\n     *         }),\n     *     dc.barChart(moveChart)\n     *         .group(volumeByMonthGroup)\n     *         .centerBar(true)\n     * ]);\n     * @param {Array<Chart>} [subChartArray]\n     * @returns {dc.compositeChart}\n     */\n    _chart.compose = function (subChartArray) {\n        _children = subChartArray;\n        _children.forEach(function (child) {\n            child.height(_chart.height());\n            child.width(_chart.width());\n            child.margins(_chart.margins());\n\n            if (_shareTitle) {\n                child.title(_chart.title());\n            }\n\n            child.options(_childOptions);\n        });\n        return _chart;\n    };\n\n    /**\n     * Returns the child charts which are composed into the composite chart.\n     * @method children\n     * @memberof dc.compositeChart\n     * @instance\n     * @returns {Array<dc.baseMixin>}\n     */\n    _chart.children = function () {\n        return _children;\n    };\n\n    /**\n     * Get or set color sharing for the chart. If set, the {@link dc.colorMixin#colors .colors()} value from this chart\n     * will be shared with composed children. Additionally if the child chart implements\n     * Stackable and has not set a custom .colorAccessor, then it will generate a color\n     * specific to its order in the composition.\n     * @method shareColors\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {Boolean} [shareColors=false]\n     * @returns {Boolean|dc.compositeChart}\n     */\n    _chart.shareColors = function (shareColors) {\n        if (!arguments.length) {\n            return _shareColors;\n        }\n        _shareColors = shareColors;\n        return _chart;\n    };\n\n    /**\n     * Get or set title sharing for the chart. If set, the {@link dc.baseMixin#title .title()} value from\n     * this chart will be shared with composed children.\n     * @method shareTitle\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {Boolean} [shareTitle=true]\n     * @returns {Boolean|dc.compositeChart}\n     */\n    _chart.shareTitle = function (shareTitle) {\n        if (!arguments.length) {\n            return _shareTitle;\n        }\n        _shareTitle = shareTitle;\n        return _chart;\n    };\n\n    /**\n     * Get or set the y scale for the right axis. The right y scale is typically automatically\n     * generated by the chart implementation.\n     * @method rightY\n     * @memberof dc.compositeChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @param {d3.scale} [yScale]\n     * @returns {d3.scale|dc.compositeChart}\n     */\n    _chart.rightY = function (yScale) {\n        if (!arguments.length) {\n            return _rightY;\n        }\n        _rightY = yScale;\n        _chart.rescale();\n        return _chart;\n    };\n\n    /**\n     * Get or set alignment between left and right y axes. A line connecting '0' on both y axis\n     * will be parallel to x axis. This only has effect when {@link #dc.coordinateGridMixin+elasticY elasticY} is true.\n     * @method alignYAxes\n     * @memberof dc.compositeChart\n     * @instance\n     * @param {Boolean} [alignYAxes=false]\n     * @returns {Chart}\n     */\n    _chart.alignYAxes = function (alignYAxes) {\n        if (!arguments.length) {\n            return _alignYAxes;\n        }\n        _alignYAxes = alignYAxes;\n        _chart.rescale();\n        return _chart;\n    };\n\n    function leftYAxisChildren () {\n        return _children.filter(function (child) {\n            return !child.useRightYAxis();\n        });\n    }\n\n    function rightYAxisChildren () {\n        return _children.filter(function (child) {\n            return child.useRightYAxis();\n        });\n    }\n\n    function getYAxisMin (charts) {\n        return charts.map(function (c) {\n            return c.yAxisMin();\n        });\n    }\n\n    delete _chart.yAxisMin;\n    function yAxisMin () {\n        return d3.min(getYAxisMin(leftYAxisChildren()));\n    }\n\n    function rightYAxisMin () {\n        return d3.min(getYAxisMin(rightYAxisChildren()));\n    }\n\n    function getYAxisMax (charts) {\n        return charts.map(function (c) {\n            return c.yAxisMax();\n        });\n    }\n\n    delete _chart.yAxisMax;\n    function yAxisMax () {\n        return dc.utils.add(d3.max(getYAxisMax(leftYAxisChildren())), _chart.yAxisPadding());\n    }\n\n    function rightYAxisMax () {\n        return dc.utils.add(d3.max(getYAxisMax(rightYAxisChildren())), _chart.yAxisPadding());\n    }\n\n    function getAllXAxisMinFromChildCharts () {\n        return _children.map(function (c) {\n            return c.xAxisMin();\n        });\n    }\n\n    dc.override(_chart, 'xAxisMin', function () {\n        return dc.utils.subtract(d3.min(getAllXAxisMinFromChildCharts()), _chart.xAxisPadding(), _chart.xAxisPaddingUnit());\n    });\n\n    function getAllXAxisMaxFromChildCharts () {\n        return _children.map(function (c) {\n            return c.xAxisMax();\n        });\n    }\n\n    dc.override(_chart, 'xAxisMax', function () {\n        return dc.utils.add(d3.max(getAllXAxisMaxFromChildCharts()), _chart.xAxisPadding(), _chart.xAxisPaddingUnit());\n    });\n\n    _chart.legendables = function () {\n        return _children.reduce(function (items, child) {\n            if (_shareColors) {\n                child.colors(_chart.colors());\n            }\n            items.push.apply(items, child.legendables());\n            return items;\n        }, []);\n    };\n\n    _chart.legendHighlight = function (d) {\n        for (var j = 0; j < _children.length; ++j) {\n            var child = _children[j];\n            child.legendHighlight(d);\n        }\n    };\n\n    _chart.legendReset = function (d) {\n        for (var j = 0; j < _children.length; ++j) {\n            var child = _children[j];\n            child.legendReset(d);\n        }\n    };\n\n    _chart.legendToggle = function () {\n        console.log('composite should not be getting legendToggle itself');\n    };\n\n    /**\n     * Set or get the right y axis used by the composite chart. This function is most useful when y\n     * axis customization is required. The y axis in dc.js is an instance of a\n     * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight) therefore it supports any valid\n     * d3 axis manipulation.\n     *\n     * **Caution**: The right y axis is usually generated internally by dc; resetting it may cause\n     * unexpected results.  Note also that when used as a getter, this function is not chainable: it\n     * returns the axis, not the chart,\n     * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis\n     * so attempting to call chart functions after calling `.yAxis()` will fail}.\n     * @method rightYAxis\n     * @memberof dc.compositeChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisRight}\n     * @example\n     * // customize y axis tick format\n     * chart.rightYAxis().tickFormat(function (v) {return v + '%';});\n     * // customize y axis tick values\n     * chart.rightYAxis().tickValues([0, 100, 200, 300]);\n     * @param {d3.axisRight} [rightYAxis]\n     * @returns {d3.axisRight|dc.compositeChart}\n     */\n    _chart.rightYAxis = function (rightYAxis) {\n        if (!arguments.length) {\n            return _rightYAxis;\n        }\n        _rightYAxis = rightYAxis;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * A series chart is a chart that shows multiple series of data overlaid on one chart, where the\n * series is specified in the data. It is a specialization of Composite Chart and inherits all\n * composite features other than recomposing the chart.\n *\n * Examples:\n * - {@link http://dc-js.github.io/dc.js/examples/series.html Series Chart}\n * @class seriesChart\n * @memberof dc\n * @mixes dc.compositeChart\n * @example\n * // create a series chart under #chart-container1 element using the default global chart group\n * var seriesChart1 = dc.seriesChart(\"#chart-container1\");\n * // create a series chart under #chart-container2 element using chart group A\n * var seriesChart2 = dc.seriesChart(\"#chart-container2\", \"chartGroupA\");\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.seriesChart}\n */\ndc.seriesChart = function (parent, chartGroup) {\n    var _chart = dc.compositeChart(parent, chartGroup);\n\n    function keySort (a, b) {\n        return d3.ascending(_chart.keyAccessor()(a), _chart.keyAccessor()(b));\n    }\n\n    var _charts = {};\n    var _chartFunction = dc.lineChart;\n    var _seriesAccessor;\n    var _seriesSort = d3.ascending;\n    var _valueSort = keySort;\n\n    _chart._mandatoryAttributes().push('seriesAccessor', 'chart');\n    _chart.shareColors(true);\n\n    _chart._preprocessData = function () {\n        var keep = [];\n        var childrenChanged;\n        var nester = d3.nest().key(_seriesAccessor);\n        if (_seriesSort) {\n            nester.sortKeys(_seriesSort);\n        }\n        if (_valueSort) {\n            nester.sortValues(_valueSort);\n        }\n        var nesting = nester.entries(_chart.data());\n        var children =\n            nesting.map(function (sub, i) {\n                var subChart = _charts[sub.key] || _chartFunction.call(_chart, _chart, chartGroup, sub.key, i);\n                if (!_charts[sub.key]) {\n                    childrenChanged = true;\n                }\n                _charts[sub.key] = subChart;\n                keep.push(sub.key);\n                return subChart\n                    .dimension(_chart.dimension())\n                    .group({\n                        all: typeof sub.values === 'function' ? sub.values : dc.utils.constant(sub.values)\n                    }, sub.key)\n                    .keyAccessor(_chart.keyAccessor())\n                    .valueAccessor(_chart.valueAccessor())\n                    .brushOn(false);\n            });\n        // this works around the fact compositeChart doesn't really\n        // have a removal interface\n        Object.keys(_charts)\n            .filter(function (c) {return keep.indexOf(c) === -1;})\n            .forEach(function (c) {\n                clearChart(c);\n                childrenChanged = true;\n            });\n        _chart._compose(children);\n        if (childrenChanged && _chart.legend()) {\n            _chart.legend().render();\n        }\n    };\n\n    function clearChart (c) {\n        if (_charts[c].g()) {\n            _charts[c].g().remove();\n        }\n        delete _charts[c];\n    }\n\n    function resetChildren () {\n        Object.keys(_charts).map(clearChart);\n        _charts = {};\n    }\n\n    /**\n     * Get or set the chart function, which generates the child charts.\n     * @method chart\n     * @memberof dc.seriesChart\n     * @instance\n     * @example\n     * // put curve on the line charts used for the series\n     * chart.chart(function(c) { return dc.lineChart(c).curve(d3.curveBasis); })\n     * // do a scatter series chart\n     * chart.chart(dc.scatterPlot)\n     * @param {Function} [chartFunction=dc.lineChart]\n     * @returns {Function|dc.seriesChart}\n     */\n    _chart.chart = function (chartFunction) {\n        if (!arguments.length) {\n            return _chartFunction;\n        }\n        _chartFunction = chartFunction;\n        resetChildren();\n        return _chart;\n    };\n\n    /**\n     * **mandatory**\n     *\n     * Get or set accessor function for the displayed series. Given a datum, this function\n     * should return the series that datum belongs to.\n     * @method seriesAccessor\n     * @memberof dc.seriesChart\n     * @instance\n     * @example\n     * // simple series accessor\n     * chart.seriesAccessor(function(d) { return \"Expt: \" + d.key[0]; })\n     * @param {Function} [accessor]\n     * @returns {Function|dc.seriesChart}\n     */\n    _chart.seriesAccessor = function (accessor) {\n        if (!arguments.length) {\n            return _seriesAccessor;\n        }\n        _seriesAccessor = accessor;\n        resetChildren();\n        return _chart;\n    };\n\n    /**\n     * Get or set a function to sort the list of series by, given series values.\n     * @method seriesSort\n     * @memberof dc.seriesChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending}\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending}\n     * @example\n     * chart.seriesSort(d3.descending);\n     * @param {Function} [sortFunction=d3.ascending]\n     * @returns {Function|dc.seriesChart}\n     */\n    _chart.seriesSort = function (sortFunction) {\n        if (!arguments.length) {\n            return _seriesSort;\n        }\n        _seriesSort = sortFunction;\n        resetChildren();\n        return _chart;\n    };\n\n    /**\n     * Get or set a function to sort each series values by. By default this is the key accessor which,\n     * for example, will ensure a lineChart series connects its points in increasing key/x order,\n     * rather than haphazardly.\n     * @method valueSort\n     * @memberof dc.seriesChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending}\n     * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending}\n     * @example\n     * // Default value sort\n     * _chart.valueSort(function keySort (a, b) {\n     *     return d3.ascending(_chart.keyAccessor()(a), _chart.keyAccessor()(b));\n     * });\n     * @param {Function} [sortFunction]\n     * @returns {Function|dc.seriesChart}\n     */\n    _chart.valueSort = function (sortFunction) {\n        if (!arguments.length) {\n            return _valueSort;\n        }\n        _valueSort = sortFunction;\n        resetChildren();\n        return _chart;\n    };\n\n    // make compose private\n    _chart._compose = _chart.compose;\n    delete _chart.compose;\n\n    return _chart;\n};\n\n/**\n * The geo choropleth chart is designed as an easy way to create a crossfilter driven choropleth map\n * from GeoJson data. This chart implementation was inspired by\n * {@link http://bl.ocks.org/4060606 the great d3 choropleth example}.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/vc/index.html US Venture Capital Landscape 2011}\n * @class geoChoroplethChart\n * @memberof dc\n * @mixes dc.colorMixin\n * @mixes dc.baseMixin\n * @example\n * // create a choropleth chart under '#us-chart' element using the default global chart group\n * var chart1 = dc.geoChoroplethChart('#us-chart');\n * // create a choropleth chart under '#us-chart2' element using chart group A\n * var chart2 = dc.compositeChart('#us-chart2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.geoChoroplethChart}\n */\ndc.geoChoroplethChart = function (parent, chartGroup) {\n    var _chart = dc.colorMixin(dc.baseMixin({}));\n\n    _chart.colorAccessor(function (d) {\n        return d || 0;\n    });\n\n    var _geoPath = d3.geoPath();\n    var _projectionFlag;\n    var _projection;\n\n    var _geoJsons = [];\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n        for (var layerIndex = 0; layerIndex < _geoJsons.length; ++layerIndex) {\n            var states = _chart.svg().append('g')\n                .attr('class', 'layer' + layerIndex);\n\n            var regionG = states.selectAll('g.' + geoJson(layerIndex).name)\n                .data(geoJson(layerIndex).data);\n\n            regionG = regionG.enter()\n                    .append('g')\n                    .attr('class', geoJson(layerIndex).name)\n                .merge(regionG);\n\n            regionG\n                .append('path')\n                .attr('fill', 'white')\n                .attr('d', _getGeoPath());\n\n            regionG.append('title');\n\n            plotData(layerIndex);\n        }\n        _projectionFlag = false;\n    };\n\n    function plotData (layerIndex) {\n        var data = generateLayeredData();\n\n        if (isDataLayer(layerIndex)) {\n            var regionG = renderRegionG(layerIndex);\n\n            renderPaths(regionG, layerIndex, data);\n\n            renderTitle(regionG, layerIndex, data);\n        }\n    }\n\n    function generateLayeredData () {\n        var data = {};\n        var groupAll = _chart.data();\n        for (var i = 0; i < groupAll.length; ++i) {\n            data[_chart.keyAccessor()(groupAll[i])] = _chart.valueAccessor()(groupAll[i]);\n        }\n        return data;\n    }\n\n    function isDataLayer (layerIndex) {\n        return geoJson(layerIndex).keyAccessor;\n    }\n\n    function renderRegionG (layerIndex) {\n        var regionG = _chart.svg()\n            .selectAll(layerSelector(layerIndex))\n            .classed('selected', function (d) {\n                return isSelected(layerIndex, d);\n            })\n            .classed('deselected', function (d) {\n                return isDeselected(layerIndex, d);\n            })\n            .attr('class', function (d) {\n                var layerNameClass = geoJson(layerIndex).name;\n                var regionClass = dc.utils.nameToId(geoJson(layerIndex).keyAccessor(d));\n                var baseClasses = layerNameClass + ' ' + regionClass;\n                if (isSelected(layerIndex, d)) {\n                    baseClasses += ' selected';\n                }\n                if (isDeselected(layerIndex, d)) {\n                    baseClasses += ' deselected';\n                }\n                return baseClasses;\n            });\n        return regionG;\n    }\n\n    function layerSelector (layerIndex) {\n        return 'g.layer' + layerIndex + ' g.' + geoJson(layerIndex).name;\n    }\n\n    function isSelected (layerIndex, d) {\n        return _chart.hasFilter() && _chart.hasFilter(getKey(layerIndex, d));\n    }\n\n    function isDeselected (layerIndex, d) {\n        return _chart.hasFilter() && !_chart.hasFilter(getKey(layerIndex, d));\n    }\n\n    function getKey (layerIndex, d) {\n        return geoJson(layerIndex).keyAccessor(d);\n    }\n\n    function geoJson (index) {\n        return _geoJsons[index];\n    }\n\n    function renderPaths (regionG, layerIndex, data) {\n        var paths = regionG\n            .select('path')\n            .attr('fill', function () {\n                var currentFill = d3.select(this).attr('fill');\n                if (currentFill) {\n                    return currentFill;\n                }\n                return 'none';\n            })\n            .on('click', function (d) {\n                return _chart.onClick(d, layerIndex);\n            });\n\n        dc.transition(paths, _chart.transitionDuration(), _chart.transitionDelay()).attr('fill', function (d, i) {\n            return _chart.getColor(data[geoJson(layerIndex).keyAccessor(d)], i);\n        });\n    }\n\n    _chart.onClick = function (d, layerIndex) {\n        var selectedRegion = geoJson(layerIndex).keyAccessor(d);\n        dc.events.trigger(function () {\n            _chart.filter(selectedRegion);\n            _chart.redrawGroup();\n        });\n    };\n\n    function renderTitle (regionG, layerIndex, data) {\n        if (_chart.renderTitle()) {\n            regionG.selectAll('title').text(function (d) {\n                var key = getKey(layerIndex, d);\n                var value = data[key];\n                return _chart.title()({key: key, value: value});\n            });\n        }\n    }\n\n    _chart._doRedraw = function () {\n        for (var layerIndex = 0; layerIndex < _geoJsons.length; ++layerIndex) {\n            plotData(layerIndex);\n            if (_projectionFlag) {\n                _chart.svg().selectAll('g.' + geoJson(layerIndex).name + ' path').attr('d', _getGeoPath());\n            }\n        }\n        _projectionFlag = false;\n    };\n\n    /**\n     * **mandatory**\n     *\n     * Use this function to insert a new GeoJson map layer. This function can be invoked multiple times\n     * if you have multiple GeoJson data layers to render on top of each other. If you overlay multiple\n     * layers with the same name the new overlay will override the existing one.\n     * @method overlayGeoJson\n     * @memberof dc.geoChoroplethChart\n     * @instance\n     * @see {@link http://geojson.org/ GeoJSON}\n     * @see {@link https://github.com/topojson/topojson/wiki TopoJSON}\n     * @see {@link https://github.com/topojson/topojson-1.x-api-reference/blob/master/API-Reference.md#wiki-feature topojson.feature}\n     * @example\n     * // insert a layer for rendering US states\n     * chart.overlayGeoJson(statesJson.features, 'state', function(d) {\n     *      return d.properties.name;\n     * });\n     * @param {geoJson} json - a geojson feed\n     * @param {String} name - name of the layer\n     * @param {Function} keyAccessor - accessor function used to extract 'key' from the GeoJson data. The key extracted by\n     * this function should match the keys returned by the crossfilter groups.\n     * @returns {dc.geoChoroplethChart}\n     */\n    _chart.overlayGeoJson = function (json, name, keyAccessor) {\n        for (var i = 0; i < _geoJsons.length; ++i) {\n            if (_geoJsons[i].name === name) {\n                _geoJsons[i].data = json;\n                _geoJsons[i].keyAccessor = keyAccessor;\n                return _chart;\n            }\n        }\n        _geoJsons.push({name: name, data: json, keyAccessor: keyAccessor});\n        return _chart;\n    };\n\n    /**\n     * Gets or sets a custom geo projection function. See the available\n     * {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3 geo projection functions}.\n     *\n     * Starting version 3.0 it has been deprecated to rely on the default projection being\n     * {@link https://github.com/d3/d3-geo/blob/master/README.md#geoAlbersUsa d3.geoAlbersUsa()}. Please\n     * set it explicitly. {@link https://bl.ocks.org/mbostock/5557726\n     * Considering that `null` is also a valid value for projection}, if you need\n     * projection to be `null` please set it explicitly to `null`.\n     * @method projection\n     * @memberof dc.geoChoroplethChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3.projection}\n     * @see {@link https://github.com/d3/d3-geo-projection d3-geo-projection}\n     * @param {d3.projection} [projection=d3.geoAlbersUsa()]\n     * @returns {d3.projection|dc.geoChoroplethChart}\n     */\n    _chart.projection = function (projection) {\n        if (!arguments.length) {\n            return _projection;\n        }\n\n        _projection = projection;\n        _projectionFlag = true;\n        return _chart;\n    };\n\n    var _getGeoPath = function () {\n        if (_projection === undefined) {\n            dc.logger.warn('choropleth projection default of geoAlbers is deprecated,' +\n                ' in next version projection will need to be set explicitly');\n            return _geoPath.projection(d3.geoAlbersUsa());\n        }\n\n        return _geoPath.projection(_projection);\n    };\n\n    /**\n     * Returns all GeoJson layers currently registered with this chart. The returned array is a\n     * reference to this chart's internal data structure, so any modification to this array will also\n     * modify this chart's internal registration.\n     * @method geoJsons\n     * @memberof dc.geoChoroplethChart\n     * @instance\n     * @returns {Array<{name:String, data: Object, accessor: Function}>}\n     */\n    _chart.geoJsons = function () {\n        return _geoJsons;\n    };\n\n    /**\n     * Returns the {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath} object used to\n     * render the projection and features.  Can be useful for figuring out the bounding box of the\n     * feature set and thus a way to calculate scale and translation for the projection.\n     * @method geoPath\n     * @memberof dc.geoChoroplethChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath}\n     * @returns {d3.geoPath}\n     */\n    _chart.geoPath = function () {\n        return _geoPath;\n    };\n\n    /**\n     * Remove a GeoJson layer from this chart by name\n     * @method removeGeoJson\n     * @memberof dc.geoChoroplethChart\n     * @instance\n     * @param {String} name\n     * @returns {dc.geoChoroplethChart}\n     */\n    _chart.removeGeoJson = function (name) {\n        var geoJsons = [];\n\n        for (var i = 0; i < _geoJsons.length; ++i) {\n            var layer = _geoJsons[i];\n            if (layer.name !== name) {\n                geoJsons.push(layer);\n            }\n        }\n\n        _geoJsons = geoJsons;\n\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The bubble overlay chart is quite different from the typical bubble chart. With the bubble overlay\n * chart you can arbitrarily place bubbles on an existing svg or bitmap image, thus changing the\n * typical x and y positioning while retaining the capability to visualize data using bubble radius\n * and coloring.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats}\n * @class bubbleOverlay\n * @memberof dc\n * @mixes dc.bubbleMixin\n * @mixes dc.baseMixin\n * @example\n * // create a bubble overlay chart on top of the '#chart-container1 svg' element using the default global chart group\n * var bubbleChart1 = dc.bubbleOverlayChart('#chart-container1').svg(d3.select('#chart-container1 svg'));\n * // create a bubble overlay chart on top of the '#chart-container2 svg' element using chart group A\n * var bubbleChart2 = dc.compositeChart('#chart-container2', 'chartGroupA').svg(d3.select('#chart-container2 svg'));\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.bubbleOverlay}\n */\ndc.bubbleOverlay = function (parent, chartGroup) {\n    var BUBBLE_OVERLAY_CLASS = 'bubble-overlay';\n    var BUBBLE_NODE_CLASS = 'node';\n    var BUBBLE_CLASS = 'bubble';\n\n    /**\n     * **mandatory**\n     *\n     * Set the underlying svg image element. Unlike other dc charts this chart will not generate a svg\n     * element; therefore the bubble overlay chart will not work if this function is not invoked. If the\n     * underlying image is a bitmap, then an empty svg will need to be created on top of the image.\n     * @method svg\n     * @memberof dc.bubbleOverlay\n     * @instance\n     * @example\n     * // set up underlying svg element\n     * chart.svg(d3.select('#chart svg'));\n     * @param {SVGElement|d3.selection} [imageElement]\n     * @returns {dc.bubbleOverlay}\n     */\n    var _chart = dc.bubbleMixin(dc.baseMixin({}));\n    var _g;\n    var _points = [];\n\n    _chart.transitionDuration(750);\n\n    _chart.transitionDelay(0);\n\n    _chart.radiusValueAccessor(function (d) {\n        return d.value;\n    });\n\n    /**\n     * **mandatory**\n     *\n     * Set up a data point on the overlay. The name of a data point should match a specific 'key' among\n     * data groups generated using keyAccessor.  If a match is found (point name <-> data group key)\n     * then a bubble will be generated at the position specified by the function. x and y\n     * value specified here are relative to the underlying svg.\n     * @method point\n     * @memberof dc.bubbleOverlay\n     * @instance\n     * @param {String} name\n     * @param {Number} x\n     * @param {Number} y\n     * @returns {dc.bubbleOverlay}\n     */\n    _chart.point = function (name, x, y) {\n        _points.push({name: name, x: x, y: y});\n        return _chart;\n    };\n\n    _chart._doRender = function () {\n        _g = initOverlayG();\n\n        _chart.r().range([_chart.MIN_RADIUS, _chart.width() * _chart.maxBubbleRelativeSize()]);\n\n        initializeBubbles();\n\n        _chart.fadeDeselectedArea(_chart.filter());\n\n        return _chart;\n    };\n\n    function initOverlayG () {\n        _g = _chart.select('g.' + BUBBLE_OVERLAY_CLASS);\n        if (_g.empty()) {\n            _g = _chart.svg().append('g').attr('class', BUBBLE_OVERLAY_CLASS);\n        }\n        return _g;\n    }\n\n    function initializeBubbles () {\n        var data = mapData();\n        _chart.calculateRadiusDomain();\n\n        _points.forEach(function (point) {\n            var nodeG = getNodeG(point, data);\n\n            var circle = nodeG.select('circle.' + BUBBLE_CLASS);\n\n            if (circle.empty()) {\n                circle = nodeG.append('circle')\n                    .attr('class', BUBBLE_CLASS)\n                    .attr('r', 0)\n                    .attr('fill', _chart.getColor)\n                    .on('click', _chart.onClick);\n            }\n\n            dc.transition(circle, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('r', function (d) {\n                    return _chart.bubbleR(d);\n                });\n\n            _chart._doRenderLabel(nodeG);\n\n            _chart._doRenderTitles(nodeG);\n        });\n    }\n\n    function mapData () {\n        var data = {};\n        _chart.data().forEach(function (datum) {\n            data[_chart.keyAccessor()(datum)] = datum;\n        });\n        return data;\n    }\n\n    function getNodeG (point, data) {\n        var bubbleNodeClass = BUBBLE_NODE_CLASS + ' ' + dc.utils.nameToId(point.name);\n\n        var nodeG = _g.select('g.' + dc.utils.nameToId(point.name));\n\n        if (nodeG.empty()) {\n            nodeG = _g.append('g')\n                .attr('class', bubbleNodeClass)\n                .attr('transform', 'translate(' + point.x + ',' + point.y + ')');\n        }\n\n        nodeG.datum(data[point.name]);\n\n        return nodeG;\n    }\n\n    _chart._doRedraw = function () {\n        updateBubbles();\n\n        _chart.fadeDeselectedArea(_chart.filter());\n\n        return _chart;\n    };\n\n    function updateBubbles () {\n        var data = mapData();\n        _chart.calculateRadiusDomain();\n\n        _points.forEach(function (point) {\n            var nodeG = getNodeG(point, data);\n\n            var circle = nodeG.select('circle.' + BUBBLE_CLASS);\n\n            dc.transition(circle, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('r', function (d) {\n                    return _chart.bubbleR(d);\n                })\n                .attr('fill', _chart.getColor);\n\n            _chart.doUpdateLabels(nodeG);\n\n            _chart.doUpdateTitles(nodeG);\n        });\n    }\n\n    _chart.debug = function (flag) {\n        if (flag) {\n            var debugG = _chart.select('g.' + dc.constants.DEBUG_GROUP_CLASS);\n\n            if (debugG.empty()) {\n                debugG = _chart.svg()\n                    .append('g')\n                    .attr('class', dc.constants.DEBUG_GROUP_CLASS);\n            }\n\n            var debugText = debugG.append('text')\n                .attr('x', 10)\n                .attr('y', 20);\n\n            debugG\n                .append('rect')\n                .attr('width', _chart.width())\n                .attr('height', _chart.height())\n                .on('mousemove', function () {\n                    var position = d3.mouse(debugG.node());\n                    var msg = position[0] + ', ' + position[1];\n                    debugText.text(msg);\n                });\n        } else {\n            _chart.selectAll('.debug').remove();\n        }\n\n        return _chart;\n    };\n\n    _chart.anchor(parent, chartGroup);\n\n    return _chart;\n};\n\n/**\n * Concrete row chart implementation.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * @class rowChart\n * @memberof dc\n * @mixes dc.capMixin\n * @mixes dc.marginMixin\n * @mixes dc.colorMixin\n * @mixes dc.baseMixin\n * @example\n * // create a row chart under #chart-container1 element using the default global chart group\n * var chart1 = dc.rowChart('#chart-container1');\n * // create a row chart under #chart-container2 element using chart group A\n * var chart2 = dc.rowChart('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.rowChart}\n */\ndc.rowChart = function (parent, chartGroup) {\n\n    var _g;\n\n    var _labelOffsetX = 10;\n    var _labelOffsetY = 15;\n    var _hasLabelOffsetY = false;\n    var _dyOffset = '0.35em';  // this helps center labels https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Shapes.md#svg_text\n    var _titleLabelOffsetX = 2;\n\n    var _gap = 5;\n\n    var _fixedBarHeight = false;\n    var _rowCssClass = 'row';\n    var _titleRowCssClass = 'titlerow';\n    var _renderTitleLabel = false;\n\n    var _chart = dc.capMixin(dc.marginMixin(dc.colorMixin(dc.baseMixin({}))));\n\n    var _x;\n\n    var _elasticX;\n\n    var _xAxis = d3.axisBottom();\n\n    var _rowData;\n\n    _chart.rowsCap = _chart.cap;\n\n    function calculateAxisScale () {\n        if (!_x || _elasticX) {\n            var extent = d3.extent(_rowData, _chart.cappedValueAccessor);\n            if (extent[0] > 0) {\n                extent[0] = 0;\n            }\n            if (extent[1] < 0) {\n                extent[1] = 0;\n            }\n            _x = d3.scaleLinear().domain(extent)\n                .range([0, _chart.effectiveWidth()]);\n        }\n        _xAxis.scale(_x);\n    }\n\n    function drawAxis () {\n        var axisG = _g.select('g.axis');\n\n        calculateAxisScale();\n\n        if (axisG.empty()) {\n            axisG = _g.append('g').attr('class', 'axis');\n        }\n        axisG.attr('transform', 'translate(0, ' + _chart.effectiveHeight() + ')');\n\n        dc.transition(axisG, _chart.transitionDuration(), _chart.transitionDelay())\n            .call(_xAxis);\n    }\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n\n        _g = _chart.svg()\n            .append('g')\n            .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')');\n\n        drawChart();\n\n        return _chart;\n    };\n\n    _chart.title(function (d) {\n        return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d);\n    });\n\n    _chart.label(_chart.cappedKeyAccessor);\n\n    /**\n     * Gets or sets the x scale. The x scale can be any d3\n     * {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}.\n     * @method x\n     * @memberof dc.rowChart\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}\n     * @param {d3.scale} [scale]\n     * @returns {d3.scale|dc.rowChart}\n     */\n    _chart.x = function (scale) {\n        if (!arguments.length) {\n            return _x;\n        }\n        _x = scale;\n        return _chart;\n    };\n\n    function drawGridLines () {\n        _g.selectAll('g.tick')\n            .select('line.grid-line')\n            .remove();\n\n        _g.selectAll('g.tick')\n            .append('line')\n            .attr('class', 'grid-line')\n            .attr('x1', 0)\n            .attr('y1', 0)\n            .attr('x2', 0)\n            .attr('y2', function () {\n                return -_chart.effectiveHeight();\n            });\n    }\n\n    function drawChart () {\n        _rowData = _chart.data();\n\n        drawAxis();\n        drawGridLines();\n\n        var rows = _g.selectAll('g.' + _rowCssClass)\n            .data(_rowData);\n\n        removeElements(rows);\n        rows = createElements(rows)\n            .merge(rows);\n        updateElements(rows);\n    }\n\n    function createElements (rows) {\n        var rowEnter = rows.enter()\n            .append('g')\n            .attr('class', function (d, i) {\n                return _rowCssClass + ' _' + i;\n            });\n\n        rowEnter.append('rect').attr('width', 0);\n\n        createLabels(rowEnter);\n\n        return rowEnter;\n    }\n\n    function removeElements (rows) {\n        rows.exit().remove();\n    }\n\n    function rootValue () {\n        var root = _x(0);\n        return (root === -Infinity || root !== root) ? _x(1) : root;\n    }\n\n    function updateElements (rows) {\n        var n = _rowData.length;\n\n        var height;\n        if (!_fixedBarHeight) {\n            height = (_chart.effectiveHeight() - (n + 1) * _gap) / n;\n        } else {\n            height = _fixedBarHeight;\n        }\n\n        // vertically align label in center unless they override the value via property setter\n        if (!_hasLabelOffsetY) {\n            _labelOffsetY = height / 2;\n        }\n\n        var rect = rows.attr('transform', function (d, i) {\n                return 'translate(0,' + ((i + 1) * _gap + i * height) + ')';\n            }).select('rect')\n            .attr('height', height)\n            .attr('fill', _chart.getColor)\n            .on('click', onClick)\n            .classed('deselected', function (d) {\n                return (_chart.hasFilter()) ? !isSelectedRow(d) : false;\n            })\n            .classed('selected', function (d) {\n                return (_chart.hasFilter()) ? isSelectedRow(d) : false;\n            });\n\n        dc.transition(rect, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('width', function (d) {\n                return Math.abs(rootValue() - _x(_chart.valueAccessor()(d)));\n            })\n            .attr('transform', translateX);\n\n        createTitles(rows);\n        updateLabels(rows);\n    }\n\n    function createTitles (rows) {\n        if (_chart.renderTitle()) {\n            rows.select('title').remove();\n            rows.append('title').text(_chart.title());\n        }\n    }\n\n    function createLabels (rowEnter) {\n        if (_chart.renderLabel()) {\n            rowEnter.append('text')\n                .on('click', onClick);\n        }\n        if (_chart.renderTitleLabel()) {\n            rowEnter.append('text')\n                .attr('class', _titleRowCssClass)\n                .on('click', onClick);\n        }\n    }\n\n    function updateLabels (rows) {\n        if (_chart.renderLabel()) {\n            var lab = rows.select('text')\n                .attr('x', _labelOffsetX)\n                .attr('y', _labelOffsetY)\n                .attr('dy', _dyOffset)\n                .on('click', onClick)\n                .attr('class', function (d, i) {\n                    return _rowCssClass + ' _' + i;\n                })\n                .text(function (d) {\n                    return _chart.label()(d);\n                });\n            dc.transition(lab, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('transform', translateX);\n        }\n        if (_chart.renderTitleLabel()) {\n            var titlelab = rows.select('.' + _titleRowCssClass)\n                    .attr('x', _chart.effectiveWidth() - _titleLabelOffsetX)\n                    .attr('y', _labelOffsetY)\n                    .attr('dy', _dyOffset)\n                    .attr('text-anchor', 'end')\n                    .on('click', onClick)\n                    .attr('class', function (d, i) {\n                        return _titleRowCssClass + ' _' + i ;\n                    })\n                    .text(function (d) {\n                        return _chart.title()(d);\n                    });\n            dc.transition(titlelab, _chart.transitionDuration(), _chart.transitionDelay())\n                .attr('transform', translateX);\n        }\n    }\n\n    /**\n     * Turn on/off Title label rendering (values) using SVG style of text-anchor 'end'.\n     * @method renderTitleLabel\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Boolean} [renderTitleLabel=false]\n     * @returns {Boolean|dc.rowChart}\n     */\n    _chart.renderTitleLabel = function (renderTitleLabel) {\n        if (!arguments.length) {\n            return _renderTitleLabel;\n        }\n        _renderTitleLabel = renderTitleLabel;\n        return _chart;\n    };\n\n    function onClick (d) {\n        _chart.onClick(d);\n    }\n\n    function translateX (d) {\n        var x = _x(_chart.cappedValueAccessor(d)),\n            x0 = rootValue(),\n            s = x > x0 ? x0 : x;\n        return 'translate(' + s + ',0)';\n    }\n\n    _chart._doRedraw = function () {\n        drawChart();\n        return _chart;\n    };\n\n    /**\n     * Get or sets the x axis for the row chart instance.\n     * See the {@link https://github.com/d3/d3-axis/blob/master/README.md d3.axis}\n     * documention for more information.\n     * @method xAxis\n     * @memberof dc.rowChart\n     * @instance\n     * @example\n     * // customize x axis tick format\n     * chart.xAxis().tickFormat(function (v) {return v + '%';});\n     * // customize x axis tick values\n     * chart.xAxis().tickValues([0, 100, 200, 300]);\n     * // use a top-oriented axis. Note: position of the axis and grid lines will need to\n     * // be set manually, see https://dc-js.github.io/dc.js/examples/row-top-axis.html\n     * chart.xAxis(d3.axisTop())\n     * @returns {d3.axis}\n     */\n    _chart.xAxis = function (xAxis) {\n        if (!arguments.length) {\n            return _xAxis;\n        }\n        _xAxis = xAxis;\n        return this;\n    };\n\n    /**\n     * Get or set the fixed bar height. Default is [false] which will auto-scale bars.\n     * For example, if you want to fix the height for a specific number of bars (useful in TopN charts)\n     * you could fix height as follows (where count = total number of bars in your TopN and gap is\n     * your vertical gap space).\n     * @method fixedBarHeight\n     * @memberof dc.rowChart\n     * @instance\n     * @example\n     * chart.fixedBarHeight( chartheight - (count + 1) * gap / count);\n     * @param {Boolean|Number} [fixedBarHeight=false]\n     * @returns {Boolean|Number|dc.rowChart}\n     */\n    _chart.fixedBarHeight = function (fixedBarHeight) {\n        if (!arguments.length) {\n            return _fixedBarHeight;\n        }\n        _fixedBarHeight = fixedBarHeight;\n        return _chart;\n    };\n\n    /**\n     * Get or set the vertical gap space between rows on a particular row chart instance.\n     * @method gap\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Number} [gap=5]\n     * @returns {Number|dc.rowChart}\n     */\n    _chart.gap = function (gap) {\n        if (!arguments.length) {\n            return _gap;\n        }\n        _gap = gap;\n        return _chart;\n    };\n\n    /**\n     * Get or set the elasticity on x axis. If this attribute is set to true, then the x axis will rescle to auto-fit the\n     * data range when filtered.\n     * @method elasticX\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Boolean} [elasticX]\n     * @returns {Boolean|dc.rowChart}\n     */\n    _chart.elasticX = function (elasticX) {\n        if (!arguments.length) {\n            return _elasticX;\n        }\n        _elasticX = elasticX;\n        return _chart;\n    };\n\n    /**\n     * Get or set the x offset (horizontal space to the top left corner of a row) for labels on a particular row chart.\n     * @method labelOffsetX\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Number} [labelOffsetX=10]\n     * @returns {Number|dc.rowChart}\n     */\n    _chart.labelOffsetX = function (labelOffsetX) {\n        if (!arguments.length) {\n            return _labelOffsetX;\n        }\n        _labelOffsetX = labelOffsetX;\n        return _chart;\n    };\n\n    /**\n     * Get or set the y offset (vertical space to the top left corner of a row) for labels on a particular row chart.\n     * @method labelOffsetY\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Number} [labelOffsety=15]\n     * @returns {Number|dc.rowChart}\n     */\n    _chart.labelOffsetY = function (labelOffsety) {\n        if (!arguments.length) {\n            return _labelOffsetY;\n        }\n        _labelOffsetY = labelOffsety;\n        _hasLabelOffsetY = true;\n        return _chart;\n    };\n\n    /**\n     * Get of set the x offset (horizontal space between right edge of row and right edge or text.\n     * @method titleLabelOffsetX\n     * @memberof dc.rowChart\n     * @instance\n     * @param {Number} [titleLabelOffsetX=2]\n     * @returns {Number|dc.rowChart}\n     */\n    _chart.titleLabelOffsetX = function (titleLabelOffsetX) {\n        if (!arguments.length) {\n            return _titleLabelOffsetX;\n        }\n        _titleLabelOffsetX = titleLabelOffsetX;\n        return _chart;\n    };\n\n    function isSelectedRow (d) {\n        return _chart.hasFilter(_chart.cappedKeyAccessor(d));\n    }\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Legend is a attachable widget that can be added to other dc charts to render horizontal legend\n * labels.\n *\n * Examples:\n * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index}\n * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats}\n * @class legend\n * @memberof dc\n * @example\n * chart.legend(dc.legend().x(400).y(10).itemHeight(13).gap(5))\n * @returns {dc.legend}\n */\ndc.legend = function () {\n    var LABEL_GAP = 2;\n\n    var _legend = {},\n        _parent,\n        _x = 0,\n        _y = 0,\n        _itemHeight = 12,\n        _gap = 5,\n        _horizontal = false,\n        _legendWidth = 560,\n        _itemWidth = 70,\n        _autoItemWidth = false,\n        _legendText = dc.pluck('name'),\n        _maxItems;\n\n    var _g;\n\n    _legend.parent = function (p) {\n        if (!arguments.length) {\n            return _parent;\n        }\n        _parent = p;\n        return _legend;\n    };\n\n    _legend.render = function () {\n        _parent.svg().select('g.dc-legend').remove();\n        _g = _parent.svg().append('g')\n            .attr('class', 'dc-legend')\n            .attr('transform', 'translate(' + _x + ',' + _y + ')');\n        var legendables = _parent.legendables();\n\n        if (_maxItems !== undefined) {\n            legendables = legendables.slice(0, _maxItems);\n        }\n\n        var itemEnter = _g.selectAll('g.dc-legend-item')\n            .data(legendables)\n            .enter()\n            .append('g')\n            .attr('class', 'dc-legend-item')\n            .on('mouseover', function (d) {\n                _parent.legendHighlight(d);\n            })\n            .on('mouseout', function (d) {\n                _parent.legendReset(d);\n            })\n            .on('click', function (d) {\n                d.chart.legendToggle(d);\n            });\n\n        _g.selectAll('g.dc-legend-item')\n            .classed('fadeout', function (d) {\n                return d.chart.isLegendableHidden(d);\n            });\n\n        if (legendables.some(dc.pluck('dashstyle'))) {\n            itemEnter\n                .append('line')\n                .attr('x1', 0)\n                .attr('y1', _itemHeight / 2)\n                .attr('x2', _itemHeight)\n                .attr('y2', _itemHeight / 2)\n                .attr('stroke-width', 2)\n                .attr('stroke-dasharray', dc.pluck('dashstyle'))\n                .attr('stroke', dc.pluck('color'));\n        } else {\n            itemEnter\n                .append('rect')\n                .attr('width', _itemHeight)\n                .attr('height', _itemHeight)\n                .attr('fill', function (d) {return d ? d.color : 'blue';});\n        }\n\n        itemEnter.append('text')\n                .text(_legendText)\n                .attr('x', _itemHeight + LABEL_GAP)\n                .attr('y', function () {\n                    return _itemHeight / 2 + (this.clientHeight ? this.clientHeight : 13) / 2 - 2;\n                });\n\n        var _cumulativeLegendTextWidth = 0;\n        var row = 0;\n        itemEnter.attr('transform', function (d, i) {\n            if (_horizontal) {\n                var itemWidth   = _autoItemWidth === true ? this.getBBox().width + _gap : _itemWidth;\n                if ((_cumulativeLegendTextWidth + itemWidth) > _legendWidth && _cumulativeLegendTextWidth > 0) {\n                    ++row;\n                    _cumulativeLegendTextWidth = 0;\n                }\n                var translateBy = 'translate(' + _cumulativeLegendTextWidth + ',' + row * legendItemHeight() + ')';\n                _cumulativeLegendTextWidth += itemWidth;\n                return translateBy;\n            } else {\n                return 'translate(0,' + i * legendItemHeight() + ')';\n            }\n        });\n    };\n\n    function legendItemHeight () {\n        return _gap + _itemHeight;\n    }\n\n    /**\n     * Set or get x coordinate for legend widget.\n     * @method x\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [x=0]\n     * @returns {Number|dc.legend}\n     */\n    _legend.x = function (x) {\n        if (!arguments.length) {\n            return _x;\n        }\n        _x = x;\n        return _legend;\n    };\n\n    /**\n     * Set or get y coordinate for legend widget.\n     * @method y\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [y=0]\n     * @returns {Number|dc.legend}\n     */\n    _legend.y = function (y) {\n        if (!arguments.length) {\n            return _y;\n        }\n        _y = y;\n        return _legend;\n    };\n\n    /**\n     * Set or get gap between legend items.\n     * @method gap\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [gap=5]\n     * @returns {Number|dc.legend}\n     */\n    _legend.gap = function (gap) {\n        if (!arguments.length) {\n            return _gap;\n        }\n        _gap = gap;\n        return _legend;\n    };\n\n    /**\n     * Set or get legend item height.\n     * @method itemHeight\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [itemHeight=12]\n     * @returns {Number|dc.legend}\n     */\n    _legend.itemHeight = function (itemHeight) {\n        if (!arguments.length) {\n            return _itemHeight;\n        }\n        _itemHeight = itemHeight;\n        return _legend;\n    };\n\n    /**\n     * Position legend horizontally instead of vertically.\n     * @method horizontal\n     * @memberof dc.legend\n     * @instance\n     * @param  {Boolean} [horizontal=false]\n     * @returns {Boolean|dc.legend}\n     */\n    _legend.horizontal = function (horizontal) {\n        if (!arguments.length) {\n            return _horizontal;\n        }\n        _horizontal = horizontal;\n        return _legend;\n    };\n\n    /**\n     * Maximum width for horizontal legend.\n     * @method legendWidth\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [legendWidth=500]\n     * @returns {Number|dc.legend}\n     */\n    _legend.legendWidth = function (legendWidth) {\n        if (!arguments.length) {\n            return _legendWidth;\n        }\n        _legendWidth = legendWidth;\n        return _legend;\n    };\n\n    /**\n     * Legend item width for horizontal legend.\n     * @method itemWidth\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [itemWidth=70]\n     * @returns {Number|dc.legend}\n     */\n    _legend.itemWidth = function (itemWidth) {\n        if (!arguments.length) {\n            return _itemWidth;\n        }\n        _itemWidth = itemWidth;\n        return _legend;\n    };\n\n    /**\n     * Turn automatic width for legend items on or off. If true, {@link dc.legend#itemWidth itemWidth} is ignored.\n     * This setting takes into account the {@link dc.legend#gap gap}.\n     * @method autoItemWidth\n     * @memberof dc.legend\n     * @instance\n     * @param  {Boolean} [autoItemWidth=false]\n     * @returns {Boolean|dc.legend}\n     */\n    _legend.autoItemWidth = function (autoItemWidth) {\n        if (!arguments.length) {\n            return _autoItemWidth;\n        }\n        _autoItemWidth = autoItemWidth;\n        return _legend;\n    };\n\n    /**\n     * Set or get the legend text function. The legend widget uses this function to render the legend\n     * text for each item. If no function is specified the legend widget will display the names\n     * associated with each group.\n     * @method legendText\n     * @memberof dc.legend\n     * @instance\n     * @param  {Function} [legendText]\n     * @returns {Function|dc.legend}\n     * @example\n     * // default legendText\n     * legend.legendText(dc.pluck('name'))\n     *\n     * // create numbered legend items\n     * chart.legend(dc.legend().legendText(function(d, i) { return i + '. ' + d.name; }))\n     *\n     * // create legend displaying group counts\n     * chart.legend(dc.legend().legendText(function(d) { return d.name + ': ' d.data; }))\n     **/\n    _legend.legendText = function (legendText) {\n        if (!arguments.length) {\n            return _legendText;\n        }\n        _legendText = legendText;\n        return _legend;\n    };\n\n    /**\n     * Maximum number of legend items to display\n     * @method maxItems\n     * @memberof dc.legend\n     * @instance\n     * @param  {Number} [maxItems]\n     * @return {dc.legend}\n     */\n    _legend.maxItems = function (maxItems) {\n        if (!arguments.length) {\n            return _maxItems;\n        }\n        _maxItems = dc.utils.isNumber(maxItems) ? maxItems : undefined;\n        return _legend;\n    };\n\n    return _legend;\n};\n\n/**\n * htmlLegend is a attachable widget that can be added to other dc charts to render horizontal/vertical legend\n * labels.\n *\n * @class htmlLegend\n * @memberof dc\n * @example\n * chart.legend(dc.htmlLegend().container(legendContainerElement).horizontal(false))\n * @returns {dc.htmlLegend}\n */\ndc.htmlLegend = function () {\n    var _legend = {},\n        _htmlLegendDivCssClass = 'dc-html-legend',\n        _legendItemCssClassHorizontal = 'dc-legend-item-horizontal',\n        _legendItemCssClassVertical = 'dc-legend-item-vertical',\n        _parent,\n        _container,\n        _legendText = dc.pluck('name'),\n        _maxItems,\n        _horizontal = false,\n        _legendItemClass,\n        _highlightSelected = false;\n\n    _legend.parent = function (p) {\n        if (!arguments.length) {\n            return _parent;\n        }\n        _parent = p;\n        return _legend;\n    };\n\n    _legend.render = function () {\n        var _defaultLegendItemCssClass = _horizontal ?  _legendItemCssClassHorizontal : _legendItemCssClassVertical;\n        _container.select('div.dc-html-legend').remove();\n\n        var _l = _container.append('div').attr('class', _htmlLegendDivCssClass);\n        _l.attr('style', 'max-width:' + _container.nodes()[0].style.width);\n\n        var legendables = _parent.legendables();\n        var filters = _parent.filters();\n\n        if (_maxItems !== undefined) {\n            legendables = legendables.slice(0, _maxItems);\n        }\n\n        var legendItemClassName = _legendItemClass ? _legendItemClass : _defaultLegendItemCssClass;\n\n        var itemEnter = _l.selectAll('div.' + legendItemClassName)\n            .data(legendables).enter()\n            .append('div')\n            .classed(legendItemClassName, true)\n            .on('mouseover', _parent.legendHighlight)\n            .on('mouseout', _parent.legendReset)\n            .on('click', _parent.legendToggle);\n\n        if (_highlightSelected) {\n            itemEnter.classed(dc.constants.SELECTED_CLASS, function (d) {\n                return filters.indexOf(d.name) !== -1;\n            });\n        }\n\n        itemEnter.append('span')\n            .attr('class', 'dc-legend-item-color')\n            .style('background-color', dc.pluck('color'));\n\n        itemEnter.append('span')\n            .attr('class', 'dc-legend-item-label')\n            .attr('title', _legendText)\n            .text(_legendText);\n    };\n\n    /**\n     #### .container([selector])\n     Set the container selector for the legend widget. Required.\n     **/\n    _legend.container = function (c) {\n        if (!arguments.length) {\n            return _container;\n        }\n        _container = d3.select(c);\n        return _legend;\n    };\n\n    /**\n     #### .legendItemClass([selector])\n     This can be optionally used to override class for legenditem and just use this class style.\n     The reason to have this is so this can be done for a particular chart rather than overriding the style for all charts\n     Setting this will disable the highlighting of selected items also.\n     **/\n    _legend.legendItemClass = function (c) {\n        if (!arguments.length) {\n            return _legendItemClass;\n        }\n        _legendItemClass = c;\n        return _legend;\n    };\n\n    /**\n     #### .highlightSelected([boolean])\n     This can be optionally used to enable highlighting legends for the selections/filters for the chart.\n     **/\n    _legend.highlightSelected = function (c) {\n        if (!arguments.length) {\n            return _highlightSelected;\n        }\n        _highlightSelected = c;\n        return _legend;\n    };\n\n    /**\n     #### .horizontal([boolean])\n     Display the legend horizontally instead of horizontally\n     **/\n    _legend.horizontal = function (b) {\n        if (!arguments.length) {\n            return _horizontal;\n        }\n        _horizontal = b;\n        return _legend;\n    };\n\n    /**\n     * Set or get the legend text function. The legend widget uses this function to render the legend\n     * text for each item. If no function is specified the legend widget will display the names\n     * associated with each group.\n     * @method legendText\n     * @memberof dc.htmlLegend\n     * @instance\n     * @param  {Function} [legendText]\n     * @returns {Function|dc.htmlLegend}\n     * @example\n     * // default legendText\n     * legend.legendText(dc.pluck('name'))\n     *\n     * // create numbered legend items\n     * chart.legend(dc.htmlLegend().legendText(function(d, i) { return i + '. ' + d.name; }))\n     *\n     * // create legend displaying group counts\n     * chart.legend(dc.htmlLegend().legendText(function(d) { return d.name + ': ' d.data; }))\n     **/\n    _legend.legendText = function (legendText) {\n        if (!arguments.length) {\n            return _legendText;\n        }\n        _legendText = legendText;\n        return _legend;\n    };\n\n    /**\n     * Maximum number of legend items to display\n     * @method maxItems\n     * @memberof dc.htmlLegend\n     * @instance\n     * @param  {Number} [maxItems]\n     * @return {dc.htmlLegend}\n     */\n    _legend.maxItems = function (maxItems) {\n        if (!arguments.length) {\n            return _maxItems;\n        }\n        _maxItems = dc.utils.isNumber(maxItems) ? maxItems : undefined;\n        return _legend;\n    };\n\n    return _legend;\n};\n\n\n/**\n * A scatter plot chart\n *\n * Examples:\n * - {@link http://dc-js.github.io/dc.js/examples/scatter.html Scatter Chart}\n * - {@link http://dc-js.github.io/dc.js/examples/multi-scatter.html Multi-Scatter Chart}\n * @class scatterPlot\n * @memberof dc\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a scatter plot under #chart-container1 element using the default global chart group\n * var chart1 = dc.scatterPlot('#chart-container1');\n * // create a scatter plot under #chart-container2 element using chart group A\n * var chart2 = dc.scatterPlot('#chart-container2', 'chartGroupA');\n * // create a sub-chart under a composite parent chart\n * var chart3 = dc.scatterPlot(compositeChart);\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.scatterPlot}\n */\ndc.scatterPlot = function (parent, chartGroup) {\n    var _chart = dc.coordinateGridMixin({});\n    var _symbol = d3.symbol();\n\n    var _existenceAccessor = function (d) { return d.value; };\n\n    var originalKeyAccessor = _chart.keyAccessor();\n    _chart.keyAccessor(function (d) { return originalKeyAccessor(d)[0]; });\n    _chart.valueAccessor(function (d) { return originalKeyAccessor(d)[1]; });\n    _chart.colorAccessor(function () { return _chart._groupName; });\n\n    _chart.title(function (d) {\n        // this basically just counteracts the setting of its own key/value accessors\n        // see https://github.com/dc-js/dc.js/issues/702\n        return _chart.keyAccessor()(d) + ',' + _chart.valueAccessor()(d) + ': ' +\n            _chart.existenceAccessor()(d);\n    });\n\n    var _locator = function (d) {\n        return 'translate(' + _chart.x()(_chart.keyAccessor()(d)) + ',' +\n                              _chart.y()(_chart.valueAccessor()(d)) + ')';\n    };\n\n    var _highlightedSize = 7;\n    var _symbolSize = 5;\n    var _excludedSize = 3;\n    var _excludedColor = null;\n    var _excludedOpacity = 1.0;\n    var _emptySize = 0;\n    var _emptyOpacity = 0;\n    var _nonemptyOpacity = 1;\n    var _emptyColor = null;\n    var _filtered = [];\n\n    // Use a 2 dimensional brush\n    _chart.brush(d3.brush());\n\n    function elementSize (d, i) {\n        if (!_existenceAccessor(d)) {\n            return Math.pow(_emptySize, 2);\n        } else if (_filtered[i]) {\n            return Math.pow(_symbolSize, 2);\n        } else {\n            return Math.pow(_excludedSize, 2);\n        }\n    }\n    _symbol.size(elementSize);\n\n    dc.override(_chart, '_filter', function (filter) {\n        if (!arguments.length) {\n            return _chart.__filter();\n        }\n\n        return _chart.__filter(dc.filters.RangedTwoDimensionalFilter(filter));\n    });\n\n    _chart.plotData = function () {\n        var symbols = _chart.chartBodyG().selectAll('path.symbol')\n            .data(_chart.data());\n\n        symbols = symbols\n            .enter()\n                .append('path')\n                .attr('class', 'symbol')\n                .attr('opacity', 0)\n                .attr('fill', _chart.getColor)\n                .attr('transform', _locator)\n            .merge(symbols);\n\n        symbols.call(renderTitles, _chart.data());\n\n        symbols.each(function (d, i) {\n            _filtered[i] = !_chart.filter() || _chart.filter().isFiltered([_chart.keyAccessor()(d), _chart.valueAccessor()(d)]);\n        });\n\n        dc.transition(symbols, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('opacity', function (d, i) {\n                if (!_existenceAccessor(d)) {\n                    return _emptyOpacity;\n                } else if (_filtered[i]) {\n                    return _nonemptyOpacity;\n                } else {\n                    return _chart.excludedOpacity();\n                }\n            })\n            .attr('fill', function (d, i) {\n                if (_emptyColor && !_existenceAccessor(d)) {\n                    return _emptyColor;\n                } else if (_chart.excludedColor() && !_filtered[i]) {\n                    return _chart.excludedColor();\n                } else {\n                    return _chart.getColor(d);\n                }\n            })\n            .attr('transform', _locator)\n            .attr('d', _symbol);\n\n        dc.transition(symbols.exit(), _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('opacity', 0).remove();\n    };\n\n    function renderTitles (symbol, d) {\n        if (_chart.renderTitle()) {\n            symbol.selectAll('title').remove();\n            symbol.append('title').text(function (d) {\n                return _chart.title()(d);\n            });\n        }\n    }\n\n    /**\n     * Get or set the existence accessor.  If a point exists, it is drawn with\n     * {@link dc.scatterPlot#symbolSize symbolSize} radius and\n     * opacity 1; if it does not exist, it is drawn with\n     * {@link dc.scatterPlot#emptySize emptySize} radius and opacity 0. By default,\n     * the existence accessor checks if the reduced value is truthy.\n     * @method existenceAccessor\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link dc.scatterPlot#symbolSize symbolSize}\n     * @see {@link dc.scatterPlot#emptySize emptySize}\n     * @example\n     * // default accessor\n     * chart.existenceAccessor(function (d) { return d.value; });\n     * @param {Function} [accessor]\n     * @returns {Function|dc.scatterPlot}\n     */\n    _chart.existenceAccessor = function (accessor) {\n        if (!arguments.length) {\n            return _existenceAccessor;\n        }\n        _existenceAccessor = accessor;\n        return this;\n    };\n\n    /**\n     * Get or set the symbol type used for each point. By default the symbol is a circle (d3.symbolCircle).\n     * Type can be a constant or an accessor.\n     * @method symbol\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_type symbol.type}\n     * @example\n     * // Circle type\n     * chart.symbol(d3.symbolCircle);\n     * // Square type\n     * chart.symbol(d3.symbolSquare);\n     * @param {Function} [type=d3.symbolCircle]\n     * @returns {Function|dc.scatterPlot}\n     */\n    _chart.symbol = function (type) {\n        if (!arguments.length) {\n            return _symbol.type();\n        }\n        _symbol.type(type);\n        return _chart;\n    };\n\n    /**\n     * Get or set the symbol generator. By default `dc.scatterPlot` will use\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol()}\n     * to generate symbols. `dc.scatterPlot` will set the\n     * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size symbol size accessor}\n     * on the symbol generator.\n     * @method customSymbol\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol}\n     * @see {@link https://stackoverflow.com/questions/25332120/create-additional-d3-js-symbols Create additional D3.js symbols}\n     * @param {String|Function} [customSymbol=d3.symbol()]\n     * @returns {String|Function|dc.scatterPlot}\n     */\n    _chart.customSymbol = function (customSymbol) {\n        if (!arguments.length) {\n            return _symbol;\n        }\n        _symbol = customSymbol;\n        _symbol.size(elementSize);\n        return _chart;\n    };\n\n    /**\n     * Set or get radius for symbols.\n     * @method symbolSize\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}\n     * @param {Number} [symbolSize=3]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.symbolSize = function (symbolSize) {\n        if (!arguments.length) {\n            return _symbolSize;\n        }\n        _symbolSize = symbolSize;\n        return _chart;\n    };\n\n    /**\n     * Set or get radius for highlighted symbols.\n     * @method highlightedSize\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}\n     * @param {Number} [highlightedSize=5]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.highlightedSize = function (highlightedSize) {\n        if (!arguments.length) {\n            return _highlightedSize;\n        }\n        _highlightedSize = highlightedSize;\n        return _chart;\n    };\n\n    /**\n     * Set or get size for symbols excluded from this chart's filter. If null, no\n     * special size is applied for symbols based on their filter status.\n     * @method excludedSize\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}\n     * @param {Number} [excludedSize=null]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.excludedSize = function (excludedSize) {\n        if (!arguments.length) {\n            return _excludedSize;\n        }\n        _excludedSize = excludedSize;\n        return _chart;\n    };\n\n    /**\n     * Set or get color for symbols excluded from this chart's filter. If null, no\n     * special color is applied for symbols based on their filter status.\n     * @method excludedColor\n     * @memberof dc.scatterPlot\n     * @instance\n     * @param {Number} [excludedColor=null]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.excludedColor = function (excludedColor) {\n        if (!arguments.length) {\n            return _excludedColor;\n        }\n        _excludedColor = excludedColor;\n        return _chart;\n    };\n\n    /**\n     * Set or get opacity for symbols excluded from this chart's filter.\n     * @method excludedOpacity\n     * @memberof dc.scatterPlot\n     * @instance\n     * @param {Number} [excludedOpacity=1.0]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.excludedOpacity = function (excludedOpacity) {\n        if (!arguments.length) {\n            return _excludedOpacity;\n        }\n        _excludedOpacity = excludedOpacity;\n        return _chart;\n    };\n\n    /**\n     * Set or get radius for symbols when the group is empty.\n     * @method emptySize\n     * @memberof dc.scatterPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size}\n     * @param {Number} [emptySize=0]\n     * @returns {Number|dc.scatterPlot}\n     */\n    _chart.hiddenSize = _chart.emptySize = function (emptySize) {\n        if (!arguments.length) {\n            return _emptySize;\n        }\n        _emptySize = emptySize;\n        return _chart;\n    };\n\n    /**\n     * Set or get color for symbols when the group is empty. If null, just use the\n     * {@link dc.colorMixin#colors colorMixin.colors} color scale zero value.\n     * @name emptyColor\n     * @memberof dc.scatterPlot\n     * @instance\n     * @param {String} [emptyColor=null]\n     * @return {String}\n     * @return {dc.scatterPlot}/\n     */\n    _chart.emptyColor = function (emptyColor) {\n        if (!arguments.length) {\n            return _emptyColor;\n        }\n        _emptyColor = emptyColor;\n        return _chart;\n    };\n\n    /**\n     * Set or get opacity for symbols when the group is empty.\n     * @name emptyOpacity\n     * @memberof dc.scatterPlot\n     * @instance\n     * @param {Number} [emptyOpacity=0]\n     * @return {Number}\n     * @return {dc.scatterPlot}\n     */\n    _chart.emptyOpacity = function (emptyOpacity) {\n        if (!arguments.length) {\n            return _emptyOpacity;\n        }\n        _emptyOpacity = emptyOpacity;\n        return _chart;\n    };\n\n    /**\n     * Set or get opacity for symbols when the group is not empty.\n     * @name nonemptyOpacity\n     * @memberof dc.scatterPlot\n     * @instance\n     * @param {Number} [nonemptyOpacity=1]\n     * @return {Number}\n     * @return {dc.scatterPlot}\n     */\n    _chart.nonemptyOpacity = function (nonemptyOpacity) {\n        if (!arguments.length) {\n            return _emptyOpacity;\n        }\n        _nonemptyOpacity = nonemptyOpacity;\n        return _chart;\n    };\n\n    _chart.legendables = function () {\n        return [{chart: _chart, name: _chart._groupName, color: _chart.getColor()}];\n    };\n\n    _chart.legendHighlight = function (d) {\n        resizeSymbolsWhere(function (symbol) {\n            return symbol.attr('fill') === d.color;\n        }, _highlightedSize);\n        _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {\n            return d3.select(this).attr('fill') !== d.color;\n        }).classed('fadeout', true);\n    };\n\n    _chart.legendReset = function (d) {\n        resizeSymbolsWhere(function (symbol) {\n            return symbol.attr('fill') === d.color;\n        }, _symbolSize);\n        _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {\n            return d3.select(this).attr('fill') !== d.color;\n        }).classed('fadeout', false);\n    };\n\n    function resizeSymbolsWhere (condition, size) {\n        var symbols = _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () {\n            return condition(d3.select(this));\n        });\n        var oldSize = _symbol.size();\n        _symbol.size(Math.pow(size, 2));\n        dc.transition(symbols, _chart.transitionDuration(), _chart.transitionDelay()).attr('d', _symbol);\n        _symbol.size(oldSize);\n    }\n\n    _chart.createBrushHandlePaths = function () {\n        // no handle paths for poly-brushes\n    };\n\n    _chart.extendBrush = function (brushSelection) {\n        if (_chart.round()) {\n            brushSelection[0] = brushSelection[0].map(_chart.round());\n            brushSelection[1] = brushSelection[1].map(_chart.round());\n        }\n        return brushSelection;\n    };\n\n    _chart.brushIsEmpty = function (brushSelection) {\n        return !brushSelection || brushSelection[0][0] >= brushSelection[1][0] || brushSelection[0][1] >= brushSelection[1][1];\n    };\n\n    _chart._brushing = function () {\n        // Avoids infinite recursion (mutual recursion between range and focus operations)\n        // Source Event will be null when brush.move is called programmatically (see below as well).\n        if (!d3.event.sourceEvent) { return; }\n\n        // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.)\n        // In this case we are more worried about this handler causing brush move programmatically which will\n        // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent)\n        // This check avoids recursive calls\n        if (d3.event.sourceEvent.type && ['start', 'brush', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) {\n            return;\n        }\n\n        var brushSelection = d3.event.selection;\n\n        // Testing with pixels is more reliable\n        var brushIsEmpty = _chart.brushIsEmpty(brushSelection);\n\n        if (brushSelection) {\n            brushSelection = brushSelection.map(function (point) {\n                return point.map(function (coord, i) {\n                    var scale = i === 0 ? _chart.x() : _chart.y();\n                    return scale.invert(coord);\n                });\n            });\n\n            brushSelection = _chart.extendBrush(brushSelection);\n\n            // The rounding process might have made brushSelection empty, so we need to recheck\n            brushIsEmpty = brushIsEmpty && _chart.brushIsEmpty(brushSelection);\n        }\n\n        _chart.redrawBrush(brushSelection, false);\n\n        var ranged2DFilter = brushIsEmpty ? null : dc.filters.RangedTwoDimensionalFilter(brushSelection);\n\n        dc.events.trigger(function () {\n            _chart.replaceFilter(ranged2DFilter);\n            _chart.redrawGroup();\n        }, dc.constants.EVENT_DELAY);\n    };\n\n    _chart.redrawBrush = function (brushSelection, doTransition) {\n        // override default x axis brush from parent chart\n        var _brush = _chart.brush();\n        var _gBrush = _chart.gBrush();\n\n        if (_chart.brushOn() && _gBrush) {\n            if (_chart.resizing()) {\n                _chart.setBrushExtents(doTransition);\n            }\n\n            if (!brushSelection) {\n                _gBrush\n                    .call(_brush.move, brushSelection);\n\n            } else {\n                brushSelection = brushSelection.map(function (point) {\n                    return point.map(function (coord, i) {\n                        var scale = i === 0 ? _chart.x() : _chart.y();\n                        return scale(coord);\n                    });\n                });\n\n                var gBrush =\n                    dc.optionalTransition(doTransition, _chart.transitionDuration(), _chart.transitionDelay())(_gBrush);\n\n                gBrush\n                    .call(_brush.move, brushSelection);\n\n            }\n        }\n\n        _chart.fadeDeselectedArea(brushSelection);\n    };\n\n    _chart.setBrushY = function (gBrush) {\n        gBrush.call(_chart.brush().y(_chart.y()));\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * A display of a single numeric value.\n * Unlike other charts, you do not need to set a dimension. Instead a group object must be provided and\n * a valueAccessor that returns a single value.\n * @class numberDisplay\n * @memberof dc\n * @mixes dc.baseMixin\n * @example\n * // create a number display under #chart-container1 element using the default global chart group\n * var display1 = dc.numberDisplay('#chart-container1');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.numberDisplay}\n */\ndc.numberDisplay = function (parent, chartGroup) {\n    var SPAN_CLASS = 'number-display';\n    var _formatNumber = d3.format('.2s');\n    var _chart = dc.baseMixin({});\n    var _html = {one: '', some: '', none: ''};\n    var _lastValue;\n\n    // dimension not required\n    _chart._mandatoryAttributes(['group']);\n\n    // default to ordering by value, to emulate old group.top(1) behavior when multiple groups\n    _chart.ordering(function (kv) { return kv.value; });\n\n    /**\n     * Gets or sets an optional object specifying HTML templates to use depending on the number\n     * displayed.  The text `%number` will be replaced with the current value.\n     * - one: HTML template to use if the number is 1\n     * - zero: HTML template to use if the number is 0\n     * - some: HTML template to use otherwise\n     * @method html\n     * @memberof dc.numberDisplay\n     * @instance\n     * @example\n     * numberWidget.html({\n     *      one:'%number record',\n     *      some:'%number records',\n     *      none:'no records'})\n     * @param {{one:String, some:String, none:String}} [html={one: '', some: '', none: ''}]\n     * @returns {{one:String, some:String, none:String}|dc.numberDisplay}\n     */\n    _chart.html = function (html) {\n        if (!arguments.length) {\n            return _html;\n        }\n        if (html.none) {\n            _html.none = html.none;//if none available\n        } else if (html.one) {\n            _html.none = html.one;//if none not available use one\n        } else if (html.some) {\n            _html.none = html.some;//if none and one not available use some\n        }\n        if (html.one) {\n            _html.one = html.one;//if one available\n        } else if (html.some) {\n            _html.one = html.some;//if one not available use some\n        }\n        if (html.some) {\n            _html.some = html.some;//if some available\n        } else if (html.one) {\n            _html.some = html.one;//if some not available use one\n        }\n        return _chart;\n    };\n\n    /**\n     * Calculate and return the underlying value of the display.\n     * @method value\n     * @memberof dc.numberDisplay\n     * @instance\n     * @returns {Number}\n     */\n    _chart.value = function () {\n        return _chart.data();\n    };\n\n    function maxBin (all) {\n        if (!all.length) {\n            return null;\n        }\n        var sorted = _chart._computeOrderedGroups(all);\n        return sorted[sorted.length - 1];\n    }\n    _chart.data(function (group) {\n        var valObj = group.value ? group.value() : maxBin(group.all());\n        return _chart.valueAccessor()(valObj);\n    });\n\n    _chart.transitionDuration(250); // good default\n    _chart.transitionDelay(0);\n\n    _chart._doRender = function () {\n        var newValue = _chart.value(),\n            span = _chart.selectAll('.' + SPAN_CLASS);\n\n        if (span.empty()) {\n            span = span.data([0])\n                .enter()\n                    .append('span')\n                    .attr('class', SPAN_CLASS)\n                .merge(span);\n        }\n\n        span.transition()\n            .duration(_chart.transitionDuration())\n            .delay(_chart.transitionDelay())\n            .ease(d3.easeQuad)\n            .tween('text', function () {\n                // [XA] don't try and interpolate from Infinity, else this breaks.\n                var interpStart = isFinite(_lastValue) ? _lastValue : 0;\n                var interp = d3.interpolateNumber(interpStart || 0, newValue);\n                _lastValue = newValue;\n\n                // need to save it in D3v4\n                var node = this;\n                return function (t) {\n                    var html = null, num = _chart.formatNumber()(interp(t));\n                    if (newValue === 0 && (_html.none !== '')) {\n                        html = _html.none;\n                    } else if (newValue === 1 && (_html.one !== '')) {\n                        html = _html.one;\n                    } else if (_html.some !== '') {\n                        html = _html.some;\n                    }\n                    node.innerHTML = html ? html.replace('%number', num) : num;\n                };\n            });\n    };\n\n    _chart._doRedraw = function () {\n        return _chart._doRender();\n    };\n\n    /**\n     * Get or set a function to format the value for the display.\n     * @method formatNumber\n     * @memberof dc.numberDisplay\n     * @instance\n     * @see {@link https://github.com/d3/d3-format/blob/master/README.md#format d3.format}\n     * @param {Function} [formatter=d3.format('.2s')]\n     * @returns {Function|dc.numberDisplay}\n     */\n    _chart.formatNumber = function (formatter) {\n        if (!arguments.length) {\n            return _formatNumber;\n        }\n        _formatNumber = formatter;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * A heat map is matrix that represents the values of two dimensions of data using colors.\n * @class heatMap\n * @memberof dc\n * @mixes dc.colorMixin\n * @mixes dc.marginMixin\n * @mixes dc.baseMixin\n * @example\n * // create a heat map under #chart-container1 element using the default global chart group\n * var heatMap1 = dc.heatMap('#chart-container1');\n * // create a heat map under #chart-container2 element using chart group A\n * var heatMap2 = dc.heatMap('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.heatMap}\n */\ndc.heatMap = function (parent, chartGroup) {\n\n    var DEFAULT_BORDER_RADIUS = 6.75;\n\n    var _chartBody;\n\n    var _cols;\n    var _rows;\n    var _colOrdering = d3.ascending;\n    var _rowOrdering = d3.ascending;\n    var _colScale = d3.scaleBand();\n    var _rowScale = d3.scaleBand();\n\n    var _xBorderRadius = DEFAULT_BORDER_RADIUS;\n    var _yBorderRadius = DEFAULT_BORDER_RADIUS;\n\n    var _chart = dc.colorMixin(dc.marginMixin(dc.baseMixin({})));\n    _chart._mandatoryAttributes(['group']);\n    _chart.title(_chart.colorAccessor());\n\n    var _colsLabel = function (d) {\n        return d;\n    };\n    var _rowsLabel = function (d) {\n        return d;\n    };\n\n    /**\n     * Set or get the column label function. The chart class uses this function to render\n     * column labels on the X axis. It is passed the column name.\n     * @method colsLabel\n     * @memberof dc.heatMap\n     * @instance\n     * @example\n     * // the default label function just returns the name\n     * chart.colsLabel(function(d) { return d; });\n     * @param  {Function} [labelFunction=function(d) { return d; }]\n     * @returns {Function|dc.heatMap}\n     */\n    _chart.colsLabel = function (labelFunction) {\n        if (!arguments.length) {\n            return _colsLabel;\n        }\n        _colsLabel = labelFunction;\n        return _chart;\n    };\n\n    /**\n     * Set or get the row label function. The chart class uses this function to render\n     * row labels on the Y axis. It is passed the row name.\n     * @method rowsLabel\n     * @memberof dc.heatMap\n     * @instance\n     * @example\n     * // the default label function just returns the name\n     * chart.rowsLabel(function(d) { return d; });\n     * @param  {Function} [labelFunction=function(d) { return d; }]\n     * @returns {Function|dc.heatMap}\n     */\n    _chart.rowsLabel = function (labelFunction) {\n        if (!arguments.length) {\n            return _rowsLabel;\n        }\n        _rowsLabel = labelFunction;\n        return _chart;\n    };\n\n    var _xAxisOnClick = function (d) { filterAxis(0, d); };\n    var _yAxisOnClick = function (d) { filterAxis(1, d); };\n    var _boxOnClick = function (d) {\n        var filter = d.key;\n        dc.events.trigger(function () {\n            _chart.filter(filter);\n            _chart.redrawGroup();\n        });\n    };\n\n    function filterAxis (axis, value) {\n        var cellsOnAxis = _chart.selectAll('.box-group').filter(function (d) {\n            return d.key[axis] === value;\n        });\n        var unfilteredCellsOnAxis = cellsOnAxis.filter(function (d) {\n            return !_chart.hasFilter(d.key);\n        });\n        dc.events.trigger(function () {\n            var selection = unfilteredCellsOnAxis.empty() ? cellsOnAxis : unfilteredCellsOnAxis;\n            var filters = selection.data().map(function (kv) {\n                return dc.filters.TwoDimensionalFilter(kv.key);\n            });\n            _chart._filter([filters]);\n            _chart.redrawGroup();\n        });\n    }\n\n    dc.override(_chart, 'filter', function (filter) {\n        if (!arguments.length) {\n            return _chart._filter();\n        }\n\n        return _chart._filter(dc.filters.TwoDimensionalFilter(filter));\n    });\n\n    /**\n     * Gets or sets the values used to create the rows of the heatmap, as an array. By default, all\n     * the values will be fetched from the data using the value accessor.\n     * @method rows\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Array<String|Number>} [rows]\n     * @returns {Array<String|Number>|dc.heatMap}\n     */\n\n    _chart.rows = function (rows) {\n        if (!arguments.length) {\n            return _rows;\n        }\n        _rows = rows;\n        return _chart;\n    };\n\n    /**\n     #### .rowOrdering([orderFunction])\n     Get or set an accessor to order the rows.  Default is d3.ascending.\n     */\n    _chart.rowOrdering = function (_) {\n        if (!arguments.length) {\n            return _rowOrdering;\n        }\n        _rowOrdering = _;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the keys used to create the columns of the heatmap, as an array. By default, all\n     * the values will be fetched from the data using the key accessor.\n     * @method cols\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Array<String|Number>} [cols]\n     * @returns {Array<String|Number>|dc.heatMap}\n     */\n    _chart.cols = function (cols) {\n        if (!arguments.length) {\n            return _cols;\n        }\n        _cols = cols;\n        return _chart;\n    };\n\n    /**\n     #### .colOrdering([orderFunction])\n     Get or set an accessor to order the cols.  Default is ascending.\n     */\n    _chart.colOrdering = function (_) {\n        if (!arguments.length) {\n            return _colOrdering;\n        }\n        _colOrdering = _;\n        return _chart;\n    };\n\n    _chart._doRender = function () {\n        _chart.resetSvg();\n\n        _chartBody = _chart.svg()\n            .append('g')\n            .attr('class', 'heatmap')\n            .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')');\n\n        return _chart._doRedraw();\n    };\n\n    _chart._doRedraw = function () {\n        var data = _chart.data(),\n            rows = _chart.rows() || data.map(_chart.valueAccessor()),\n            cols = _chart.cols() || data.map(_chart.keyAccessor());\n        if (_rowOrdering) {\n            rows = rows.sort(_rowOrdering);\n        }\n        if (_colOrdering) {\n            cols = cols.sort(_colOrdering);\n        }\n        rows = _rowScale.domain(rows);\n        cols = _colScale.domain(cols);\n\n        var rowCount = rows.domain().length,\n            colCount = cols.domain().length,\n            boxWidth = Math.floor(_chart.effectiveWidth() / colCount),\n            boxHeight = Math.floor(_chart.effectiveHeight() / rowCount);\n\n        cols.rangeRound([0, _chart.effectiveWidth()]);\n        rows.rangeRound([_chart.effectiveHeight(), 0]);\n\n        var boxes = _chartBody.selectAll('g.box-group').data(_chart.data(), function (d, i) {\n            return _chart.keyAccessor()(d, i) + '\\0' + _chart.valueAccessor()(d, i);\n        });\n\n        boxes.exit().remove();\n\n        var gEnter = boxes.enter().append('g')\n            .attr('class', 'box-group');\n\n        gEnter.append('rect')\n            .attr('class', 'heat-box')\n            .attr('fill', 'white')\n            .attr('x', function (d, i) { return cols(_chart.keyAccessor()(d, i)); })\n            .attr('y', function (d, i) { return rows(_chart.valueAccessor()(d, i)); })\n            .on('click', _chart.boxOnClick());\n\n        if (_chart.renderTitle()) {\n            gEnter.append('title');\n            boxes.select('title').text(_chart.title());\n        }\n\n        boxes = gEnter.merge(boxes);\n\n        dc.transition(boxes.select('rect'), _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('x', function (d, i) { return cols(_chart.keyAccessor()(d, i)); })\n            .attr('y', function (d, i) { return rows(_chart.valueAccessor()(d, i)); })\n            .attr('rx', _xBorderRadius)\n            .attr('ry', _yBorderRadius)\n            .attr('fill', _chart.getColor)\n            .attr('width', boxWidth)\n            .attr('height', boxHeight);\n\n        var gCols = _chartBody.select('g.cols');\n        if (gCols.empty()) {\n            gCols = _chartBody.append('g').attr('class', 'cols axis');\n        }\n        var gColsText = gCols.selectAll('text').data(cols.domain());\n\n        gColsText.exit().remove();\n\n        gColsText = gColsText\n            .enter()\n                .append('text')\n                .attr('x', function (d) {\n                    return cols(d) + boxWidth / 2;\n                })\n                .style('text-anchor', 'middle')\n                .attr('y', _chart.effectiveHeight())\n                .attr('dy', 12)\n                .on('click', _chart.xAxisOnClick())\n                .text(_chart.colsLabel())\n            .merge(gColsText);\n\n        dc.transition(gColsText, _chart.transitionDuration(), _chart.transitionDelay())\n               .text(_chart.colsLabel())\n               .attr('x', function (d) { return cols(d) + boxWidth / 2; })\n               .attr('y', _chart.effectiveHeight());\n\n        var gRows = _chartBody.select('g.rows');\n        if (gRows.empty()) {\n            gRows = _chartBody.append('g').attr('class', 'rows axis');\n        }\n\n        var gRowsText = gRows.selectAll('text').data(rows.domain());\n\n        gRowsText.exit().remove();\n\n        gRowsText = gRowsText\n            .enter()\n            .append('text')\n                .style('text-anchor', 'end')\n                .attr('x', 0)\n                .attr('dx', -2)\n                .attr('y', function (d) { return rows(d) + boxHeight / 2; })\n                .attr('dy', 6)\n                .on('click', _chart.yAxisOnClick())\n                .text(_chart.rowsLabel())\n            .merge(gRowsText);\n\n        dc.transition(gRowsText, _chart.transitionDuration(), _chart.transitionDelay())\n              .text(_chart.rowsLabel())\n              .attr('y', function (d) { return rows(d) + boxHeight / 2; });\n\n        if (_chart.hasFilter()) {\n            _chart.selectAll('g.box-group').each(function (d) {\n                if (_chart.isSelectedNode(d)) {\n                    _chart.highlightSelected(this);\n                } else {\n                    _chart.fadeDeselected(this);\n                }\n            });\n        } else {\n            _chart.selectAll('g.box-group').each(function () {\n                _chart.resetHighlight(this);\n            });\n        }\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the handler that fires when an individual cell is clicked in the heatmap.\n     * By default, filtering of the cell will be toggled.\n     * @method boxOnClick\n     * @memberof dc.heatMap\n     * @instance\n     * @example\n     * // default box on click handler\n     * chart.boxOnClick(function (d) {\n     *     var filter = d.key;\n     *     dc.events.trigger(function () {\n     *         _chart.filter(filter);\n     *         _chart.redrawGroup();\n     *     });\n     * });\n     * @param  {Function} [handler]\n     * @returns {Function|dc.heatMap}\n     */\n    _chart.boxOnClick = function (handler) {\n        if (!arguments.length) {\n            return _boxOnClick;\n        }\n        _boxOnClick = handler;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the handler that fires when a column tick is clicked in the x axis.\n     * By default, if any cells in the column are unselected, the whole column will be selected,\n     * otherwise the whole column will be unselected.\n     * @method xAxisOnClick\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Function} [handler]\n     * @returns {Function|dc.heatMap}\n     */\n    _chart.xAxisOnClick = function (handler) {\n        if (!arguments.length) {\n            return _xAxisOnClick;\n        }\n        _xAxisOnClick = handler;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the handler that fires when a row tick is clicked in the y axis.\n     * By default, if any cells in the row are unselected, the whole row will be selected,\n     * otherwise the whole row will be unselected.\n     * @method yAxisOnClick\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Function} [handler]\n     * @returns {Function|dc.heatMap}\n     */\n    _chart.yAxisOnClick = function (handler) {\n        if (!arguments.length) {\n            return _yAxisOnClick;\n        }\n        _yAxisOnClick = handler;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the X border radius.  Set to 0 to get full rectangles.\n     * @method xBorderRadius\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Number} [xBorderRadius=6.75]\n     * @returns {Number|dc.heatMap}\n     */\n    _chart.xBorderRadius = function (xBorderRadius) {\n        if (!arguments.length) {\n            return _xBorderRadius;\n        }\n        _xBorderRadius = xBorderRadius;\n        return _chart;\n    };\n\n    /**\n     * Gets or sets the Y border radius.  Set to 0 to get full rectangles.\n     * @method yBorderRadius\n     * @memberof dc.heatMap\n     * @instance\n     * @param  {Number} [yBorderRadius=6.75]\n     * @returns {Number|dc.heatMap}\n     */\n    _chart.yBorderRadius = function (yBorderRadius) {\n        if (!arguments.length) {\n            return _yBorderRadius;\n        }\n        _yBorderRadius = yBorderRadius;\n        return _chart;\n    };\n\n    _chart.isSelectedNode = function (d) {\n        return _chart.hasFilter(d.key);\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n// https://github.com/d3/d3-plugins/blob/master/box/box.js\n(function () {\n\n    // Inspired by http://informationandvisualization.de/blog/box-plot\n    d3.box = function () {\n        var width = 1,\n            height = 1,\n            duration = 0,\n            delay = 0,\n            domain = null,\n            value = Number,\n            whiskers = boxWhiskers,\n            quartiles = boxQuartiles,\n            tickFormat = null;\n\n        // For each small multiple…\n        function box (g) {\n            g.each(function (d, i) {\n                d = d.map(value).sort(d3.ascending);\n                var g = d3.select(this),\n                    n = d.length,\n                    min = d[0],\n                    max = d[n - 1];\n\n                // Compute quartiles. Must return exactly 3 elements.\n                var quartileData = d.quartiles = quartiles(d);\n\n                // Compute whiskers. Must return exactly 2 elements, or null.\n                var whiskerIndices = whiskers && whiskers.call(this, d, i),\n                    whiskerData = whiskerIndices && whiskerIndices.map(function (i) { return d[i]; });\n\n                // Compute outliers. If no whiskers are specified, all data are 'outliers'.\n                // We compute the outliers as indices, so that we can join across transitions!\n                var outlierIndices = whiskerIndices ?\n                    d3.range(0, whiskerIndices[0]).concat(d3.range(whiskerIndices[1] + 1, n)) : d3.range(n);\n\n                // Compute the new x-scale.\n                var x1 = d3.scaleLinear()\n                    .domain(domain && domain.call(this, d, i) || [min, max])\n                    .range([height, 0]);\n\n                // Retrieve the old x-scale, if this is an update.\n                var x0 = this.__chart__ || d3.scaleLinear()\n                    .domain([0, Infinity])\n                    .range(x1.range());\n\n                // Stash the new scale.\n                this.__chart__ = x1;\n\n                // Note: the box, median, and box tick elements are fixed in number,\n                // so we only have to handle enter and update. In contrast, the outliers\n                // and other elements are variable, so we need to exit them! Variable\n                // elements also fade in and out.\n\n                // Update center line: the vertical line spanning the whiskers.\n                var center = g.selectAll('line.center')\n                    .data(whiskerData ? [whiskerData] : []);\n\n                center.enter().insert('line', 'rect')\n                    .attr('class', 'center')\n                    .attr('x1', width / 2)\n                    .attr('y1', function (d) { return x0(d[0]); })\n                    .attr('x2', width / 2)\n                    .attr('y2', function (d) { return x0(d[1]); })\n                    .style('opacity', 1e-6)\n                    .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .style('opacity', 1)\n                    .attr('y1', function (d) { return x1(d[0]); })\n                    .attr('y2', function (d) { return x1(d[1]); });\n\n                center.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .style('opacity', 1)\n                    .attr('x1', width / 2)\n                    .attr('x2', width / 2)\n                    .attr('y1', function (d) { return x1(d[0]); })\n                    .attr('y2', function (d) { return x1(d[1]); });\n\n                center.exit().transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .style('opacity', 1e-6)\n                    .attr('y1', function (d) { return x1(d[0]); })\n                    .attr('y2', function (d) { return x1(d[1]); })\n                    .remove();\n\n                // Update innerquartile box.\n                var box = g.selectAll('rect.box')\n                    .data([quartileData]);\n\n                box.enter().append('rect')\n                    .attr('class', 'box')\n                    .attr('x', 0)\n                    .attr('y', function (d) { return x0(d[2]); })\n                    .attr('width', width)\n                    .attr('height', function (d) { return x0(d[0]) - x0(d[2]); })\n                  .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y', function (d) { return x1(d[2]); })\n                    .attr('height', function (d) { return x1(d[0]) - x1(d[2]); });\n\n                box.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('width', width)\n                    .attr('y', function (d) { return x1(d[2]); })\n                    .attr('height', function (d) { return x1(d[0]) - x1(d[2]); });\n\n                // Update median line.\n                var medianLine = g.selectAll('line.median')\n                    .data([quartileData[1]]);\n\n                medianLine.enter().append('line')\n                    .attr('class', 'median')\n                    .attr('x1', 0)\n                    .attr('y1', x0)\n                    .attr('x2', width)\n                    .attr('y2', x0)\n                    .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y1', x1)\n                    .attr('y2', x1);\n\n                medianLine.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('x1', 0)\n                    .attr('x2', width)\n                    .attr('y1', x1)\n                    .attr('y2', x1);\n\n                // Update whiskers.\n                var whisker = g.selectAll('line.whisker')\n                    .data(whiskerData || []);\n\n                whisker.enter().insert('line', 'circle, text')\n                    .attr('class', 'whisker')\n                    .attr('x1', 0)\n                    .attr('y1', x0)\n                    .attr('x2', width)\n                    .attr('y2', x0)\n                    .style('opacity', 1e-6)\n                  .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y1', x1)\n                    .attr('y2', x1)\n                    .style('opacity', 1);\n\n                whisker.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('x1', 0)\n                    .attr('x2', width)\n                    .attr('y1', x1)\n                    .attr('y2', x1)\n                    .style('opacity', 1);\n\n                whisker.exit().transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y1', x1)\n                    .attr('y2', x1)\n                    .style('opacity', 1e-6)\n                    .remove();\n\n                // Update outliers.\n                var outlier = g.selectAll('circle.outlier')\n                    .data(outlierIndices, Number);\n\n                outlier.enter().insert('circle', 'text')\n                    .attr('class', 'outlier')\n                    .attr('r', 5)\n                    .attr('cx', width / 2)\n                    .attr('cy', function (i) { return x0(d[i]); })\n                    .style('opacity', 1e-6)\n                    .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('cy', function (i) { return x1(d[i]); })\n                    .style('opacity', 1);\n\n                outlier.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('cx', width / 2)\n                    .attr('cy', function (i) { return x1(d[i]); })\n                    .style('opacity', 1);\n\n                outlier.exit().transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('cy', function (i) { return x1(d[i]); })\n                    .style('opacity', 1e-6)\n                    .remove();\n\n                // Compute the tick format.\n                var format = tickFormat || x1.tickFormat(8);\n\n                // Update box ticks.\n                var boxTick = g.selectAll('text.box')\n                    .data(quartileData);\n\n                boxTick.enter().append('text')\n                    .attr('class', 'box')\n                    .attr('dy', '.3em')\n                    .attr('dx', function (d, i) { return i & 1 ? 6 : -6; })\n                    .attr('x', function (d, i) { return i & 1 ? width : 0; })\n                    .attr('y', x0)\n                    .attr('text-anchor', function (d, i) { return i & 1 ? 'start' : 'end'; })\n                    .text(format)\n                    .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y', x1);\n\n                boxTick.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .text(format)\n                    .attr('x', function (d, i) { return i & 1 ? width : 0; })\n                    .attr('y', x1);\n\n                // Update whisker ticks. These are handled separately from the box\n                // ticks because they may or may not exist, and we want don't want\n                // to join box ticks pre-transition with whisker ticks post-.\n                var whiskerTick = g.selectAll('text.whisker')\n                    .data(whiskerData || []);\n\n                whiskerTick.enter().append('text')\n                    .attr('class', 'whisker')\n                    .attr('dy', '.3em')\n                    .attr('dx', 6)\n                    .attr('x', width)\n                    .attr('y', x0)\n                    .text(format)\n                    .style('opacity', 1e-6)\n                    .transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y', x1)\n                    .style('opacity', 1);\n\n                whiskerTick.transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .text(format)\n                    .attr('x', width)\n                    .attr('y', x1)\n                    .style('opacity', 1);\n\n                whiskerTick.exit().transition()\n                    .duration(duration)\n                    .delay(delay)\n                    .attr('y', x1)\n                    .style('opacity', 1e-6)\n                    .remove();\n            });\n            d3.timerFlush();\n        }\n\n        box.width = function (x) {\n            if (!arguments.length) {\n                return width;\n            }\n            width = x;\n            return box;\n        };\n\n        box.height = function (x) {\n            if (!arguments.length) {\n                return height;\n            }\n            height = x;\n            return box;\n        };\n\n        box.tickFormat = function (x) {\n            if (!arguments.length) {\n                return tickFormat;\n            }\n            tickFormat = x;\n            return box;\n        };\n\n        box.duration = function (x) {\n            if (!arguments.length) {\n                return duration;\n            }\n            duration = x;\n            return box;\n        };\n\n        box.domain = function (x) {\n            if (!arguments.length) {\n                return domain;\n            }\n            domain = x === null ? x :  typeof x === 'function' ? x : dc.utils.constant(x);\n            return box;\n        };\n\n        box.value = function (x) {\n            if (!arguments.length) {\n                return value;\n            }\n            value = x;\n            return box;\n        };\n\n        box.whiskers = function (x) {\n            if (!arguments.length) {\n                return whiskers;\n            }\n            whiskers = x;\n            return box;\n        };\n\n        box.quartiles = function (x) {\n            if (!arguments.length) {\n                return quartiles;\n            }\n            quartiles = x;\n            return box;\n        };\n\n        return box;\n    };\n\n    function boxWhiskers (d) {\n        return [0, d.length - 1];\n    }\n\n    function boxQuartiles (d) {\n        return [\n            d3.quantile(d, 0.25),\n            d3.quantile(d, 0.5),\n            d3.quantile(d, 0.75)\n        ];\n    }\n\n})();\n\n\n/**\n * A box plot is a chart that depicts numerical data via their quartile ranges.\n *\n * Examples:\n * - {@link http://dc-js.github.io/dc.js/examples/box-plot-time.html Box plot time example}\n * - {@link http://dc-js.github.io/dc.js/examples/box-plot.html Box plot example}\n * @class boxPlot\n * @memberof dc\n * @mixes dc.coordinateGridMixin\n * @example\n * // create a box plot under #chart-container1 element using the default global chart group\n * var boxPlot1 = dc.boxPlot('#chart-container1');\n * // create a box plot under #chart-container2 element using chart group A\n * var boxPlot2 = dc.boxPlot('#chart-container2', 'chartGroupA');\n * @param {String|node|d3.selection} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.boxPlot}\n */\ndc.boxPlot = function (parent, chartGroup) {\n    var _chart = dc.coordinateGridMixin({});\n\n    // Returns a function to compute the interquartile range.\n    function DEFAULT_WHISKERS_IQR (k) {\n        return function (d) {\n            var q1 = d.quartiles[0],\n                q3 = d.quartiles[2],\n                iqr = (q3 - q1) * k,\n                i = -1,\n                j = d.length;\n            do { ++i; } while (d[i] < q1 - iqr);\n            do { --j; } while (d[j] > q3 + iqr);\n            return [i, j];\n        };\n    }\n\n    var _whiskerIqrFactor = 1.5;\n    var _whiskersIqr = DEFAULT_WHISKERS_IQR;\n    var _whiskers = _whiskersIqr(_whiskerIqrFactor);\n\n    var _box = d3.box();\n    var _tickFormat = null;\n\n    var _boxWidth = function (innerChartWidth, xUnits) {\n        if (_chart.isOrdinal()) {\n            return _chart.x().bandwidth();\n        } else {\n            return innerChartWidth / (1 + _chart.boxPadding()) / xUnits;\n        }\n    };\n\n    // default padding to handle min/max whisker text\n    _chart.yAxisPadding(12);\n\n    // default to ordinal\n    _chart.x(d3.scaleBand());\n    _chart.xUnits(dc.units.ordinal);\n\n    // valueAccessor should return an array of values that can be coerced into numbers\n    // or if data is overloaded for a static array of arrays, it should be `Number`.\n    // Empty arrays are not included.\n    _chart.data(function (group) {\n        return group.all().map(function (d) {\n            d.map = function (accessor) { return accessor.call(d, d); };\n            return d;\n        }).filter(function (d) {\n            var values = _chart.valueAccessor()(d);\n            return values.length !== 0;\n        });\n    });\n\n    /**\n     * Get or set the spacing between boxes as a fraction of box size. Valid values are within 0-1.\n     * See the {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs}\n     * for a visual description of how the padding is applied.\n     * @method boxPadding\n     * @memberof dc.boxPlot\n     * @instance\n     * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3.scaleBand}\n     * @param {Number} [padding=0.8]\n     * @returns {Number|dc.boxPlot}\n     */\n    _chart.boxPadding = _chart._rangeBandPadding;\n    _chart.boxPadding(0.8);\n\n    /**\n     * Get or set the outer padding on an ordinal box chart. This setting has no effect on non-ordinal charts\n     * or on charts with a custom {@link dc.boxPlot#boxWidth .boxWidth}. Will pad the width by\n     * `padding * barWidth` on each side of the chart.\n     * @method outerPadding\n     * @memberof dc.boxPlot\n     * @instance\n     * @param {Number} [padding=0.5]\n     * @returns {Number|dc.boxPlot}\n     */\n    _chart.outerPadding = _chart._outerRangeBandPadding;\n    _chart.outerPadding(0.5);\n\n    /**\n     * Get or set the numerical width of the boxplot box. The width may also be a function taking as\n     * parameters the chart width excluding the right and left margins, as well as the number of x\n     * units.\n     * @example\n     * // Using numerical parameter\n     * chart.boxWidth(10);\n     * // Using function\n     * chart.boxWidth((innerChartWidth, xUnits) { ... });\n     * @method boxWidth\n     * @memberof dc.boxPlot\n     * @instance\n     * @param {Number|Function} [boxWidth=0.5]\n     * @returns {Number|Function|dc.boxPlot}\n     */\n    _chart.boxWidth = function (boxWidth) {\n        if (!arguments.length) {\n            return _boxWidth;\n        }\n        _boxWidth = typeof boxWidth === 'function' ? boxWidth : dc.utils.constant(boxWidth);\n        return _chart;\n    };\n\n    var boxTransform = function (d, i) {\n        var xOffset = _chart.x()(_chart.keyAccessor()(d, i));\n        return 'translate(' + xOffset + ', 0)';\n    };\n\n    _chart._preprocessData = function () {\n        if (_chart.elasticX()) {\n            _chart.x().domain([]);\n        }\n    };\n\n    _chart.plotData = function () {\n        var _calculatedBoxWidth = _boxWidth(_chart.effectiveWidth(), _chart.xUnitCount());\n\n        _box.whiskers(_whiskers)\n            .width(_calculatedBoxWidth)\n            .height(_chart.effectiveHeight())\n            .value(_chart.valueAccessor())\n            .domain(_chart.y().domain())\n            .duration(_chart.transitionDuration())\n            .tickFormat(_tickFormat);\n\n        var boxesG = _chart.chartBodyG().selectAll('g.box').data(_chart.data(), _chart.keyAccessor());\n\n        var boxesGEnterUpdate = renderBoxes(boxesG);\n        updateBoxes(boxesGEnterUpdate);\n        removeBoxes(boxesG);\n\n        _chart.fadeDeselectedArea(_chart.filter());\n    };\n\n    function renderBoxes (boxesG) {\n        var boxesGEnter = boxesG.enter().append('g');\n\n        boxesGEnter\n            .attr('class', 'box')\n            .attr('transform', boxTransform)\n            .call(_box)\n            .on('click', function (d) {\n                _chart.filter(_chart.keyAccessor()(d));\n                _chart.redrawGroup();\n            });\n\n        return boxesGEnter.merge(boxesG);\n    }\n\n    function updateBoxes (boxesG) {\n        dc.transition(boxesG, _chart.transitionDuration(), _chart.transitionDelay())\n            .attr('transform', boxTransform)\n            .call(_box)\n            .each(function () {\n                d3.select(this).select('rect.box').attr('fill', _chart.getColor);\n            });\n    }\n\n    function removeBoxes (boxesG) {\n        boxesG.exit().remove().call(_box);\n    }\n\n    _chart.fadeDeselectedArea = function (brushSelection) {\n        if (_chart.hasFilter()) {\n            if (_chart.isOrdinal()) {\n                _chart.g().selectAll('g.box').each(function (d) {\n                    if (_chart.isSelectedNode(d)) {\n                        _chart.highlightSelected(this);\n                    } else {\n                        _chart.fadeDeselected(this);\n                    }\n                });\n            } else {\n                if (!(_chart.brushOn() || _chart.parentBrushOn())) {\n                    return;\n                }\n                var start = brushSelection[0];\n                var end = brushSelection[1];\n                var keyAccessor = _chart.keyAccessor();\n                _chart.g().selectAll('g.box').each(function (d) {\n                    var key = keyAccessor(d);\n                    if (key < start || key >= end) {\n                        _chart.fadeDeselected(this);\n                    } else {\n                        _chart.highlightSelected(this);\n                    }\n                });\n            }\n        } else {\n            _chart.g().selectAll('g.box').each(function () {\n                _chart.resetHighlight(this);\n            });\n        }\n    };\n\n    _chart.isSelectedNode = function (d) {\n        return _chart.hasFilter(_chart.keyAccessor()(d));\n    };\n\n    _chart.yAxisMin = function () {\n        var min = d3.min(_chart.data(), function (e) {\n            return d3.min(_chart.valueAccessor()(e));\n        });\n        return dc.utils.subtract(min, _chart.yAxisPadding());\n    };\n\n    _chart.yAxisMax = function () {\n        var max = d3.max(_chart.data(), function (e) {\n            return d3.max(_chart.valueAccessor()(e));\n        });\n        return dc.utils.add(max, _chart.yAxisPadding());\n    };\n\n    /**\n     * Set the numerical format of the boxplot median, whiskers and quartile labels. Defaults to\n     * integer formatting.\n     * @example\n     * // format ticks to 2 decimal places\n     * chart.tickFormat(d3.format('.2f'));\n     * @method tickFormat\n     * @memberof dc.boxPlot\n     * @instance\n     * @param {Function} [tickFormat]\n     * @returns {Number|Function|dc.boxPlot}\n     */\n    _chart.tickFormat = function (tickFormat) {\n        if (!arguments.length) {\n            return _tickFormat;\n        }\n        _tickFormat = tickFormat;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The select menu is a simple widget designed to filter a dimension by selecting an option from\n * an HTML `<select/>` menu. The menu can be optionally turned into a multiselect.\n * @class selectMenu\n * @memberof dc\n * @mixes dc.baseMixin\n * @example\n * // create a select menu under #select-container using the default global chart group\n * var select = dc.selectMenu('#select-container')\n *                .dimension(states)\n *                .group(stateGroup);\n * // the option text can be set via the title() function\n * // by default the option text is '`key`: `value`'\n * select.title(function (d){\n *     return 'STATE: ' + d.key;\n * })\n * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid\n * [d3 single selector](https://github.com/mbostock/d3/wiki/Selections#selecting-elements) specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this widget should be placed in.\n * Interaction with the widget will only trigger events and redraws within its group.\n * @returns {selectMenu}\n **/\ndc.selectMenu = function (parent, chartGroup) {\n    var SELECT_CSS_CLASS = 'dc-select-menu';\n    var OPTION_CSS_CLASS = 'dc-select-option';\n\n    var _chart = dc.baseMixin({});\n\n    var _select;\n    var _promptText = 'Select all';\n    var _multiple = false;\n    var _promptValue = null;\n    var _numberVisible = null;\n    var _order = function (a, b) {\n        return _chart.keyAccessor()(a) > _chart.keyAccessor()(b) ?\n             1 : _chart.keyAccessor()(b) > _chart.keyAccessor()(a) ?\n            -1 : 0;\n    };\n\n    var _filterDisplayed = function (d) {\n        return _chart.valueAccessor()(d) > 0;\n    };\n\n    _chart.data(function (group) {\n        return group.all().filter(_filterDisplayed);\n    });\n\n    _chart._doRender = function () {\n        _chart.select('select').remove();\n        _select = _chart.root().append('select')\n                        .classed(SELECT_CSS_CLASS, true);\n        _select.append('option').text(_promptText).attr('value', '');\n\n        _chart._doRedraw();\n        return _chart;\n    };\n    // Fixing IE 11 crash when redrawing the chart\n    // see here for list of IE user Agents :\n    // http://www.useragentstring.com/pages/useragentstring.php?name=Internet+Explorer\n    var ua = window.navigator.userAgent;\n    // test for IE 11 but not a lower version (which contains MSIE in UA)\n    if (ua.indexOf('Trident/') > 0 && ua.indexOf('MSIE') === -1) {\n        _chart.redraw = _chart.render;\n    }\n\n    _chart._doRedraw = function () {\n        setAttributes();\n        renderOptions();\n        // select the option(s) corresponding to current filter(s)\n        if (_chart.hasFilter() && _multiple) {\n            _select.selectAll('option')\n                .property('selected', function (d) {\n                    return typeof d !== 'undefined' && _chart.filters().indexOf(String(_chart.keyAccessor()(d))) >= 0;\n                });\n        } else if (_chart.hasFilter()) {\n            _select.property('value', _chart.filter());\n        } else {\n            _select.property('value', '');\n        }\n        return _chart;\n    };\n\n    function renderOptions () {\n        var options = _select.selectAll('option.' + OPTION_CSS_CLASS)\n          .data(_chart.data(), function (d) { return _chart.keyAccessor()(d); });\n\n        options.exit().remove();\n\n        options.enter()\n              .append('option')\n              .classed(OPTION_CSS_CLASS, true)\n              .attr('value', function (d) { return _chart.keyAccessor()(d); })\n            .merge(options)\n              .text(_chart.title());\n\n        _select.selectAll('option.' + OPTION_CSS_CLASS).sort(_order);\n\n        _select.on('change', onChange);\n    }\n\n    function onChange (d, i) {\n        var values;\n        var target = d3.event.target;\n        if (target.selectedOptions) {\n            var selectedOptions = Array.prototype.slice.call(target.selectedOptions);\n            values = selectedOptions.map(function (d) {\n                return d.value;\n            });\n        } else { // IE and other browsers do not support selectedOptions\n            // adapted from this polyfill: https://gist.github.com/brettz9/4212217\n            var options = [].slice.call(d3.event.target.options);\n            values = options.filter(function (option) {\n                return option.selected;\n            }).map(function (option) {\n                return option.value;\n            });\n        }\n        // console.log(values);\n        // check if only prompt option is selected\n        if (values.length === 1 && values[0] === '') {\n            values = _promptValue || null;\n        } else if (!_multiple && values.length === 1) {\n            values = values[0];\n        }\n        _chart.onChange(values);\n    }\n\n    _chart.onChange = function (val) {\n        if (val && _multiple) {\n            _chart.replaceFilter([val]);\n        } else if (val) {\n            _chart.replaceFilter(val);\n        } else {\n            _chart.filterAll();\n        }\n        dc.events.trigger(function () {\n            _chart.redrawGroup();\n        });\n    };\n\n    function setAttributes () {\n        if (_multiple) {\n            _select.attr('multiple', true);\n        } else {\n            _select.attr('multiple', null);\n        }\n        if (_numberVisible !== null) {\n            _select.attr('size', _numberVisible);\n        } else {\n            _select.attr('size', null);\n        }\n    }\n\n    /**\n     * Get or set the function that controls the ordering of option tags in the\n     * select menu. By default options are ordered by the group key in ascending\n     * order.\n     * @name order\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {Function} [order]\n     * @example\n     * // order by the group's value\n     * chart.order(function (a,b) {\n     *     return a.value > b.value ? 1 : b.value > a.value ? -1 : 0;\n     * });\n     **/\n    _chart.order = function (order) {\n        if (!arguments.length) {\n            return _order;\n        }\n        _order = order;\n        return _chart;\n    };\n\n    /**\n     * Get or set the text displayed in the options used to prompt selection.\n     * @name promptText\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {String} [promptText='Select all']\n     * @example\n     * chart.promptText('All states');\n     **/\n    _chart.promptText = function (_) {\n        if (!arguments.length) {\n            return _promptText;\n        }\n        _promptText = _;\n        return _chart;\n    };\n\n    /**\n     * Get or set the function that filters option tags prior to display. By default options\n     * with a value of < 1 are not displayed.\n     * @name filterDisplayed\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {function} [filterDisplayed]\n     * @example\n     * // display all options override the `filterDisplayed` function:\n     * chart.filterDisplayed(function () {\n     *     return true;\n     * });\n     **/\n    _chart.filterDisplayed = function (filterDisplayed) {\n        if (!arguments.length) {\n            return _filterDisplayed;\n        }\n        _filterDisplayed = filterDisplayed;\n        return _chart;\n    };\n\n    /**\n     * Controls the type of select menu. Setting it to true converts the underlying\n     * HTML tag into a multiple select.\n     * @name multiple\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {boolean} [multiple=false]\n     * @example\n     * chart.multiple(true);\n     **/\n    _chart.multiple = function (multiple) {\n        if (!arguments.length) {\n            return _multiple;\n        }\n        _multiple = multiple;\n\n        return _chart;\n    };\n\n    /**\n     * Controls the default value to be used for\n     * [dimension.filter](https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter)\n     * when only the prompt value is selected. If `null` (the default), no filtering will occur when\n     * just the prompt is selected.\n     * @name promptValue\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {?*} [promptValue=null]\n     **/\n    _chart.promptValue = function (promptValue) {\n        if (!arguments.length) {\n            return _promptValue;\n        }\n        _promptValue = promptValue;\n\n        return _chart;\n    };\n\n    /**\n     * Controls the number of items to show in the select menu, when `.multiple()` is true. This\n     * controls the [`size` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#Attributes) of\n     * the `select` element. If `null` (the default), uses the browser's default height.\n     * @name numberItems\n     * @memberof dc.selectMenu\n     * @instance\n     * @param {?number} [numberVisible=null]\n     * @example\n     * chart.numberVisible(10);\n     **/\n    _chart.numberVisible = function (numberVisible) {\n        if (!arguments.length) {\n            return _numberVisible;\n        }\n        _numberVisible = numberVisible;\n\n        return _chart;\n    };\n\n    _chart.size = dc.logger.deprecate(_chart.numberVisible, 'selectMenu.size is ambiguous - use numberVisible instead');\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * Text Filter Widget\n *\n * The text filter widget is a simple widget designed to display an input field allowing to filter\n * data that matches the text typed.\n * As opposed to the other charts, this doesn't display any result and doesn't update its display,\n * it's just to input an filter other charts.\n *\n * @class textFilterWidget\n * @memberof dc\n * @mixes dc.baseMixin\n * @example\n *\n * var data = [{\"firstName\":\"John\",\"lastName\":\"Coltrane\"}{\"firstName\":\"Miles\",lastName:\"Davis\"}]\n * var ndx = crossfilter(data);\n * var dimension = ndx.dimension(function(d) {\n *     return d.lastName.toLowerCase() + ' ' + d.firstName.toLowerCase();\n * });\n *\n * dc.textFilterWidget('#search')\n *     .dimension(dimension);\n *     // you don't need the group() function\n *\n * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid\n * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector}\n * specifying a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in.\n * Interaction with a chart will only trigger events and redraws within the chart's group.\n * @returns {dc.textFilterWidget}\n **/\n\ndc.textFilterWidget = function (parent, chartGroup) {\n    var INPUT_CSS_CLASS = 'dc-text-filter-input';\n\n    var _chart = dc.baseMixin({});\n\n    var _normalize = function (s) {\n        return s.toLowerCase();\n    };\n\n    var _filterFunctionFactory = function (query) {\n        query = _normalize(query);\n        return function (d) {\n            return _normalize(d).indexOf(query) !== -1;\n        };\n    };\n\n    var _placeHolder = 'search';\n\n    _chart.group(function () {\n        throw 'the group function on textFilterWidget should never be called, please report the issue';\n    });\n\n    _chart._doRender = function () {\n        _chart.select('input').remove();\n\n        var _input = _chart.root().append('input')\n            .classed(INPUT_CSS_CLASS, true);\n\n        _input.on('input', function () {\n            _chart.dimension().filterFunction(_filterFunctionFactory(this.value));\n            dc.events.trigger(function () {\n                dc.redrawAll();\n            }, dc.constants.EVENT_DELAY);\n        });\n\n        _chart._doRedraw();\n\n        return _chart;\n    };\n\n    _chart._doRedraw = function () {\n        _chart.root().selectAll('input')\n            .attr('placeholder', _placeHolder);\n\n        return _chart;\n    };\n\n    /**\n     * This function will be called on values before calling the filter function.\n     * @name normalize\n     * @memberof dc.textFilterWidget\n     * @instance\n     * @example\n     * // This is the default\n     * chart.normalize(function (s) {\n     *   return s.toLowerCase();\n     * });\n     * @param {function} [normalize]\n     * @returns {dc.textFilterWidget|function}\n     **/\n    _chart.normalize = function (normalize) {\n        if (!arguments.length) {\n            return _normalize;\n        }\n        _normalize = normalize;\n        return _chart;\n    };\n\n    /**\n     * Placeholder text in the search box.\n     * @name placeHolder\n     * @memberof dc.textFilterWidget\n     * @instance\n     * @example\n     * // This is the default\n     * chart.placeHolder('type to filter');\n     * @param {function} [placeHolder='search']\n     * @returns {dc.textFilterWidget|string}\n     **/\n    _chart.placeHolder = function (placeHolder) {\n        if (!arguments.length) {\n            return _placeHolder;\n        }\n        _placeHolder = placeHolder;\n        return _chart;\n    };\n\n    /**\n     * This function will be called with the search text, it needs to return a function that will be used to\n     * filter the data. The default function checks presence of the search text.\n     * @name filterFunctionFactory\n     * @memberof dc.textFilterWidget\n     * @instance\n     * @example\n     * // This is the default\n     * function (query) {\n     *     query = _normalize(query);\n     *     return function (d) {\n     *         return _normalize(d).indexOf(query) !== -1;\n     *     };\n     * };\n     * @param {function} [filterFunctionFactory]\n     * @returns {dc.textFilterWidget|function}\n     **/\n    _chart.filterFunctionFactory = function (filterFunctionFactory) {\n        if (!arguments.length) {\n            return _filterFunctionFactory;\n        }\n        _filterFunctionFactory = filterFunctionFactory;\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n/**\n * The cboxMenu is a simple widget designed to filter a dimension by\n * selecting option(s) from a set of HTML `<input />` elements. The menu can be\n * made into a set of radio buttons (single select) or checkboxes (multiple).\n * @class cboxMenu\n * @memberof dc\n * @mixes dc.baseMixin\n * @example\n * // create a cboxMenu under #cbox-container using the default global chart group\n * var cbox = dc.cboxMenu('#cbox-container')\n *                .dimension(states)\n *                .group(stateGroup);\n * // the option text can be set via the title() function\n * // by default the option text is '`key`: `value`'\n * cbox.title(function (d){\n *     return 'STATE: ' + d.key;\n * })\n * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid\n * [d3 single selector](https://github.com/mbostock/d3/wiki/Selections#selecting-elements) specifying\n * a dom block element such as a div; or a dom element or d3 selection.\n * @param {String} [chartGroup] - The name of the chart group this widget should be placed in.\n * Interaction with the widget will only trigger events and redraws within its group.\n * @returns {cboxMenu}\n **/\ndc.cboxMenu = function (parent, chartGroup) {\n    var GROUP_CSS_CLASS = 'dc-cbox-group';\n    var ITEM_CSS_CLASS = 'dc-cbox-item';\n\n    var _chart = dc.baseMixin({});\n\n    var _cbox;\n    var _promptText = 'Select all';\n    var _multiple = false;\n    var _inputType = 'radio';\n    var _promptValue = null;\n    // generate a random number to use as an ID\n    var _randVal = Math.floor(Math.random() * (100000)) + 1;\n    var _order = function (a, b) {\n        return _chart.keyAccessor()(a) > _chart.keyAccessor()(b) ?\n             1 : _chart.keyAccessor()(b) > _chart.keyAccessor()(a) ?\n            -1 : 0;\n    };\n\n    var _filterDisplayed = function (d) {\n        return _chart.valueAccessor()(d) > 0;\n    };\n\n    _chart.data(function (group) {\n        return group.all().filter(_filterDisplayed);\n    });\n\n    _chart._doRender = function () {\n        return _chart._doRedraw();\n    };\n    /*\n    // IS THIS NEEDED?\n    // Fixing IE 11 crash when redrawing the chart\n    // see here for list of IE user Agents :\n    // http://www.useragentstring.com/pages/useragentstring.php?name=Internet+Explorer\n    var ua = window.navigator.userAgent;\n    // test for IE 11 but not a lower version (which contains MSIE in UA)\n    if (ua.indexOf('Trident/') > 0 && ua.indexOf('MSIE') === -1) {\n        _chart.redraw = _chart.render;\n    }\n    */\n    _chart._doRedraw = function () {\n        _chart.select('ul').remove();\n        _cbox = _chart.root()\n            .append('ul')\n            .classed(GROUP_CSS_CLASS, true);\n        renderOptions();\n\n        if (_chart.hasFilter() && _multiple) {\n            _cbox.selectAll('input')\n                .property('checked', function (d) {\n                    // adding `false` avoids failing test cases in phantomjs\n                    return d && _chart.filters().indexOf(String(_chart.keyAccessor()(d))) >= 0 || false;\n                });\n        } else if (_chart.hasFilter()) {\n            _cbox.selectAll('input')\n                .property('checked', function (d) {\n                    if (!d) {\n                        return false;\n                    }\n                    return _chart.keyAccessor()(d) === _chart.filter();\n                });\n        }\n        return _chart;\n    };\n\n    function renderOptions () {\n        var options = _cbox\n        .selectAll('li.' + ITEM_CSS_CLASS)\n            .data(_chart.data(), function (d) {\n            return _chart.keyAccessor()(d);\n        });\n\n        options.exit().remove();\n\n        options = options.enter()\n                .append('li')\n                .classed(ITEM_CSS_CLASS, true)\n            .merge(options);\n\n        options\n            .append('input')\n            .attr('type', _inputType)\n            .attr('value', function (d) { return _chart.keyAccessor()(d); })\n            .attr('name', 'domain_' + _randVal)\n            .attr('id', function (d, i) {\n                return 'input_' + _randVal + '_' + i;\n            });\n        options\n            .append('label')\n            .attr('for', function (d, i) {\n                return 'input_' + _randVal + '_' + i;\n            })\n            .text(_chart.title());\n\n        // 'all' option\n        if (_multiple) {\n            _cbox\n            .append('li')\n            .append('input')\n            .attr('type', 'reset')\n            .text(_promptText)\n            .on('click', onChange);\n        } else {\n            var li = _cbox.append('li');\n            li.append('input')\n                .attr('type', _inputType)\n                .attr('value', _promptValue)\n                .attr('name', 'domain_' + _randVal)\n                .attr('id', function (d, i) {\n                    return 'input_' + _randVal + '_all';\n                })\n                .property('checked', true);\n            li.append('label')\n                .attr('for', function (d, i) {\n                    return 'input_' + _randVal + '_all';\n                })\n                .text(_promptText);\n        }\n\n        _cbox\n            .selectAll('li.' + ITEM_CSS_CLASS)\n            .sort(_order);\n\n        _cbox.on('change', onChange);\n        return options;\n    }\n\n    function onChange (d, i) {\n        var values,\n            target = d3.select(d3.event.target),\n            options;\n\n        if (!target.datum()) {\n            values = _promptValue || null;\n        } else {\n            options = d3.select(this).selectAll('input')\n            .filter(function (o) {\n                if (o) {\n                    return this.checked;\n                }\n            });\n            values = options.nodes().map(function (option) {\n                return option.value;\n            });\n            // check if only prompt option is selected\n            if (!_multiple && values.length === 1) {\n                values = values[0];\n            }\n        }\n        _chart.onChange(values);\n    }\n\n    _chart.onChange = function (val) {\n        if (val && _multiple) {\n            _chart.replaceFilter([val]);\n        } else if (val) {\n            _chart.replaceFilter(val);\n        } else {\n            _chart.filterAll();\n        }\n        dc.events.trigger(function () {\n            _chart.redrawGroup();\n        });\n    };\n\n    /**\n     * Get or set the function that controls the ordering of option tags in the\n     * cbox menu. By default options are ordered by the group key in ascending\n     * order.\n     * @name order\n     * @memberof dc.cboxMenu\n     * @instance\n     * @param {Function} [order]\n     * @example\n     * // order by the group's value\n     * chart.order(function (a,b) {\n     *     return a.value > b.value ? 1 : b.value > a.value ? -1 : 0;\n     * });\n     **/\n    _chart.order = function (order) {\n        if (!arguments.length) {\n            return _order;\n        }\n        _order = order;\n        return _chart;\n    };\n\n    /**\n     * Get or set the text displayed in the options used to prompt selection.\n     * @name promptText\n     * @memberof dc.cboxMenu\n     * @instance\n     * @param {String} [promptText='Select all']\n     * @example\n     * chart.promptText('All states');\n     **/\n    _chart.promptText = function (_) {\n        if (!arguments.length) {\n            return _promptText;\n        }\n        _promptText = _;\n        return _chart;\n    };\n\n    /**\n     * Get or set the function that filters options prior to display. By default options\n     * with a value of < 1 are not displayed.\n     * @name filterDisplayed\n     * @memberof dc.cboxMenu\n     * @instance\n     * @param {function} [filterDisplayed]\n     * @example\n     * // display all options override the `filterDisplayed` function:\n     * chart.filterDisplayed(function () {\n     *     return true;\n     * });\n     **/\n    _chart.filterDisplayed = function (filterDisplayed) {\n        if (!arguments.length) {\n            return _filterDisplayed;\n        }\n        _filterDisplayed = filterDisplayed;\n        return _chart;\n    };\n\n    /**\n     * Controls the type of input element. Setting it to true converts\n     * the HTML `input` tags from radio buttons to checkboxes.\n     * @name multiple\n     * @memberof dc.cboxMenu\n     * @instance\n     * @param {boolean} [multiple=false]\n     * @example\n     * chart.multiple(true);\n     **/\n    _chart.multiple = function (multiple) {\n        if (!arguments.length) {\n            return _multiple;\n        }\n        _multiple = multiple;\n        if (_multiple) {\n            _inputType = 'checkbox';\n        } else {\n            _inputType = 'radio';\n        }\n        return _chart;\n    };\n\n    /**\n     * Controls the default value to be used for\n     * [dimension.filter](https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter)\n     * when only the prompt value is selected. If `null` (the default), no filtering will occur when\n     * just the prompt is selected.\n     * @name promptValue\n     * @memberof dc.cboxMenu\n     * @instance\n     * @param {?*} [promptValue=null]\n     **/\n    _chart.promptValue = function (promptValue) {\n        if (!arguments.length) {\n            return _promptValue;\n        }\n        _promptValue = promptValue;\n\n        return _chart;\n    };\n\n    return _chart.anchor(parent, chartGroup);\n};\n\n// Renamed functions\n\ndc.abstractBubbleChart = dc.bubbleMixin;\ndc.baseChart = dc.baseMixin;\ndc.capped = dc.capMixin;\ndc.colorChart = dc.colorMixin;\ndc.coordinateGridChart = dc.coordinateGridMixin;\ndc.marginable = dc.marginMixin;\ndc.stackableChart = dc.stackMixin;\n\n// Expose d3 and crossfilter, so that clients in browserify\n// case can obtain them if they need them.\ndc.d3 = d3;\ndc.crossfilter = crossfilter;\n\nreturn dc;}\n    if(typeof define === \"function\" && define.amd) {\n        define([\"d3\", \"crossfilter2\"], _dc);\n    } else if(typeof module === \"object\" && module.exports) {\n        var _d3 = require('d3');\n        var _crossfilter = require('crossfilter2');\n        // When using npm + browserify, 'crossfilter' is a function,\n        // since package.json specifies index.js as main function, and it\n        // does special handling. When using bower + browserify,\n        // there's no main in bower.json (in fact, there's no bower.json),\n        // so we need to fix it.\n        if (typeof _crossfilter !== \"function\") {\n            _crossfilter = _crossfilter.crossfilter;\n        }\n        module.exports = _dc(_d3, _crossfilter);\n    } else {\n        this.dc = _dc(d3, crossfilter);\n    }\n}\n)();\n"
  },
  {
    "path": "js/admin/jquery-ui/css/smoothness/jquery-ui.css",
    "content": "/*\n* jQuery UI CSS Framework\n* Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)\n* Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses.\n*/\n\n/* Layout helpers\n----------------------------------*/\n.ui-helper-hidden { display: none; }\n.ui-helper-hidden-accessible { position: absolute; left: -99999999px; }\n.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }\n.ui-helper-clearfix:after { content: \".\"; display: block; height: 0; clear: both; visibility: hidden; }\n.ui-helper-clearfix { display: inline-block; }\n/* required comment for clearfix to work in Opera \\*/\n* html .ui-helper-clearfix { height:1%; }\n.ui-helper-clearfix { display:block; }\n/* end clearfix */\n.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }\n\n\n/* Interaction Cues\n----------------------------------*/\n.ui-state-disabled { cursor: default !important; }\n\n\n/* Icons\n----------------------------------*/\n\n/* states and images */\n.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }\n\n\n/* Misc visuals\n----------------------------------*/\n\n/* Overlays */\n.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }\n\n\n/*\n* jQuery UI CSS Framework\n* Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about)\n* Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses.\n* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,Arial,sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=01_flat.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=02_glass.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px\n*/\n\n\n/* Component containers\n----------------------------------*/\n.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; }\n.ui-widget .ui-widget { font-size: 1em; }\n.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; }\n.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; }\n.ui-widget-content a { color: #222222; }\n.ui-widget-header { border: 1px solid #aaaaaa; background: #cccccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; }\n.ui-widget-header a { color: #222222; }\n\n/* Interaction states\n----------------------------------*/\n.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3; background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #555555; }\n.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; }\n.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999; background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }\n.ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; }\n.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }\n.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; text-decoration: none; }\n.ui-widget :active { outline: none; }\n\n/* Interaction Cues\n----------------------------------*/\n.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight  {border: 1px solid #fcefa1; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; }\n.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; }\n.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #cd0a0a; }\n.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a; }\n.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a; }\n.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }\n.ui-priority-secondary, .ui-widget-content .ui-priority-secondary,  .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }\n.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }\n\n/* Icons\n----------------------------------*/\n\n/* states and images */\n.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); }\n.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }\n.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }\n.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); }\n.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }\n.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }\n.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); }\n.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); }\n\n/* positioning */\n.ui-icon-carat-1-n { background-position: 0 0; }\n.ui-icon-carat-1-ne { background-position: -16px 0; }\n.ui-icon-carat-1-e { background-position: -32px 0; }\n.ui-icon-carat-1-se { background-position: -48px 0; }\n.ui-icon-carat-1-s { background-position: -64px 0; }\n.ui-icon-carat-1-sw { background-position: -80px 0; }\n.ui-icon-carat-1-w { background-position: -96px 0; }\n.ui-icon-carat-1-nw { background-position: -112px 0; }\n.ui-icon-carat-2-n-s { background-position: -128px 0; }\n.ui-icon-carat-2-e-w { background-position: -144px 0; }\n.ui-icon-triangle-1-n { background-position: 0 -16px; }\n.ui-icon-triangle-1-ne { background-position: -16px -16px; }\n.ui-icon-triangle-1-e { background-position: -32px -16px; }\n.ui-icon-triangle-1-se { background-position: -48px -16px; }\n.ui-icon-triangle-1-s { background-position: -64px -16px; }\n.ui-icon-triangle-1-sw { background-position: -80px -16px; }\n.ui-icon-triangle-1-w { background-position: -96px -16px; }\n.ui-icon-triangle-1-nw { background-position: -112px -16px; }\n.ui-icon-triangle-2-n-s { background-position: -128px -16px; }\n.ui-icon-triangle-2-e-w { background-position: -144px -16px; }\n.ui-icon-arrow-1-n { background-position: 0 -32px; }\n.ui-icon-arrow-1-ne { background-position: -16px -32px; }\n.ui-icon-arrow-1-e { background-position: -32px -32px; }\n.ui-icon-arrow-1-se { background-position: -48px -32px; }\n.ui-icon-arrow-1-s { background-position: -64px -32px; }\n.ui-icon-arrow-1-sw { background-position: -80px -32px; }\n.ui-icon-arrow-1-w { background-position: -96px -32px; }\n.ui-icon-arrow-1-nw { background-position: -112px -32px; }\n.ui-icon-arrow-2-n-s { background-position: -128px -32px; }\n.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }\n.ui-icon-arrow-2-e-w { background-position: -160px -32px; }\n.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }\n.ui-icon-arrowstop-1-n { background-position: -192px -32px; }\n.ui-icon-arrowstop-1-e { background-position: -208px -32px; }\n.ui-icon-arrowstop-1-s { background-position: -224px -32px; }\n.ui-icon-arrowstop-1-w { background-position: -240px -32px; }\n.ui-icon-arrowthick-1-n { background-position: 0 -48px; }\n.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }\n.ui-icon-arrowthick-1-e { background-position: -32px -48px; }\n.ui-icon-arrowthick-1-se { background-position: -48px -48px; }\n.ui-icon-arrowthick-1-s { background-position: -64px -48px; }\n.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }\n.ui-icon-arrowthick-1-w { background-position: -96px -48px; }\n.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }\n.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }\n.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }\n.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }\n.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }\n.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }\n.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }\n.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }\n.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }\n.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }\n.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }\n.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }\n.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }\n.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }\n.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }\n.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }\n.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }\n.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }\n.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }\n.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }\n.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }\n.ui-icon-arrow-4 { background-position: 0 -80px; }\n.ui-icon-arrow-4-diag { background-position: -16px -80px; }\n.ui-icon-extlink { background-position: -32px -80px; }\n.ui-icon-newwin { background-position: -48px -80px; }\n.ui-icon-refresh { background-position: -64px -80px; }\n.ui-icon-shuffle { background-position: -80px -80px; }\n.ui-icon-transfer-e-w { background-position: -96px -80px; }\n.ui-icon-transferthick-e-w { background-position: -112px -80px; }\n.ui-icon-folder-collapsed { background-position: 0 -96px; }\n.ui-icon-folder-open { background-position: -16px -96px; }\n.ui-icon-document { background-position: -32px -96px; }\n.ui-icon-document-b { background-position: -48px -96px; }\n.ui-icon-note { background-position: -64px -96px; }\n.ui-icon-mail-closed { background-position: -80px -96px; }\n.ui-icon-mail-open { background-position: -96px -96px; }\n.ui-icon-suitcase { background-position: -112px -96px; }\n.ui-icon-comment { background-position: -128px -96px; }\n.ui-icon-person { background-position: -144px -96px; }\n.ui-icon-print { background-position: -160px -96px; }\n.ui-icon-trash { background-position: -176px -96px; }\n.ui-icon-locked { background-position: -192px -96px; }\n.ui-icon-unlocked { background-position: -208px -96px; }\n.ui-icon-bookmark { background-position: -224px -96px; }\n.ui-icon-tag { background-position: -240px -96px; }\n.ui-icon-home { background-position: 0 -112px; }\n.ui-icon-flag { background-position: -16px -112px; }\n.ui-icon-calendar { background-position: -32px -112px; }\n.ui-icon-cart { background-position: -48px -112px; }\n.ui-icon-pencil { background-position: -64px -112px; }\n.ui-icon-clock { background-position: -80px -112px; }\n.ui-icon-disk { background-position: -96px -112px; }\n.ui-icon-calculator { background-position: -112px -112px; }\n.ui-icon-zoomin { background-position: -128px -112px; }\n.ui-icon-zoomout { background-position: -144px -112px; }\n.ui-icon-search { background-position: -160px -112px; }\n.ui-icon-wrench { background-position: -176px -112px; }\n.ui-icon-gear { background-position: -192px -112px; }\n.ui-icon-heart { background-position: -208px -112px; }\n.ui-icon-star { background-position: -224px -112px; }\n.ui-icon-link { background-position: -240px -112px; }\n.ui-icon-cancel { background-position: 0 -128px; }\n.ui-icon-plus { background-position: -16px -128px; }\n.ui-icon-plusthick { background-position: -32px -128px; }\n.ui-icon-minus { background-position: -48px -128px; }\n.ui-icon-minusthick { background-position: -64px -128px; }\n.ui-icon-close { background-position: -80px -128px; }\n.ui-icon-closethick { background-position: -96px -128px; }\n.ui-icon-key { background-position: -112px -128px; }\n.ui-icon-lightbulb { background-position: -128px -128px; }\n.ui-icon-scissors { background-position: -144px -128px; }\n.ui-icon-clipboard { background-position: -160px -128px; }\n.ui-icon-copy { background-position: -176px -128px; }\n.ui-icon-contact { background-position: -192px -128px; }\n.ui-icon-image { background-position: -208px -128px; }\n.ui-icon-video { background-position: -224px -128px; }\n.ui-icon-script { background-position: -240px -128px; }\n.ui-icon-alert { background-position: 0 -144px; }\n.ui-icon-info { background-position: -16px -144px; }\n.ui-icon-notice { background-position: -32px -144px; }\n.ui-icon-help { background-position: -48px -144px; }\n.ui-icon-check { background-position: -64px -144px; }\n.ui-icon-bullet { background-position: -80px -144px; }\n.ui-icon-radio-off { background-position: -96px -144px; }\n.ui-icon-radio-on { background-position: -112px -144px; }\n.ui-icon-pin-w { background-position: -128px -144px; }\n.ui-icon-pin-s { background-position: -144px -144px; }\n.ui-icon-play { background-position: 0 -160px; }\n.ui-icon-pause { background-position: -16px -160px; }\n.ui-icon-seek-next { background-position: -32px -160px; }\n.ui-icon-seek-prev { background-position: -48px -160px; }\n.ui-icon-seek-end { background-position: -64px -160px; }\n.ui-icon-seek-start { background-position: -80px -160px; }\n/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */\n.ui-icon-seek-first { background-position: -80px -160px; }\n.ui-icon-stop { background-position: -96px -160px; }\n.ui-icon-eject { background-position: -112px -160px; }\n.ui-icon-volume-off { background-position: -128px -160px; }\n.ui-icon-volume-on { background-position: -144px -160px; }\n.ui-icon-power { background-position: 0 -176px; }\n.ui-icon-signal-diag { background-position: -16px -176px; }\n.ui-icon-signal { background-position: -32px -176px; }\n.ui-icon-battery-0 { background-position: -48px -176px; }\n.ui-icon-battery-1 { background-position: -64px -176px; }\n.ui-icon-battery-2 { background-position: -80px -176px; }\n.ui-icon-battery-3 { background-position: -96px -176px; }\n.ui-icon-circle-plus { background-position: 0 -192px; }\n.ui-icon-circle-minus { background-position: -16px -192px; }\n.ui-icon-circle-close { background-position: -32px -192px; }\n.ui-icon-circle-triangle-e { background-position: -48px -192px; }\n.ui-icon-circle-triangle-s { background-position: -64px -192px; }\n.ui-icon-circle-triangle-w { background-position: -80px -192px; }\n.ui-icon-circle-triangle-n { background-position: -96px -192px; }\n.ui-icon-circle-arrow-e { background-position: -112px -192px; }\n.ui-icon-circle-arrow-s { background-position: -128px -192px; }\n.ui-icon-circle-arrow-w { background-position: -144px -192px; }\n.ui-icon-circle-arrow-n { background-position: -160px -192px; }\n.ui-icon-circle-zoomin { background-position: -176px -192px; }\n.ui-icon-circle-zoomout { background-position: -192px -192px; }\n.ui-icon-circle-check { background-position: -208px -192px; }\n.ui-icon-circlesmall-plus { background-position: 0 -208px; }\n.ui-icon-circlesmall-minus { background-position: -16px -208px; }\n.ui-icon-circlesmall-close { background-position: -32px -208px; }\n.ui-icon-squaresmall-plus { background-position: -48px -208px; }\n.ui-icon-squaresmall-minus { background-position: -64px -208px; }\n.ui-icon-squaresmall-close { background-position: -80px -208px; }\n.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }\n.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }\n.ui-icon-grip-solid-vertical { background-position: -32px -224px; }\n.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }\n.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }\n.ui-icon-grip-diagonal-se { background-position: -80px -224px; }\n\n\n/* Misc visuals\n----------------------------------*/\n\n/* Corner radius */\n.ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; }\n.ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; }\n.ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }\n.ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }\n.ui-corner-top { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; }\n.ui-corner-bottom { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }\n.ui-corner-right {  -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }\n.ui-corner-left { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }\n.ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; border-radius: 4px; }\n\n/* Overlays */\n.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); }\n.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }/* Resizable\n----------------------------------*/\n.ui-resizable { position: relative;}\n.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block;}\n.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; }\n.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; }\n.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; }\n.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; }\n.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; }\n.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }\n.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; }\n.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; }\n.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/* Selectable\n----------------------------------*/\n.ui-selectable-helper { border:1px dotted black }\n/* Accordion\n----------------------------------*/\n.ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; }\n.ui-accordion .ui-accordion-li-fix { display: inline; }\n.ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; }\n.ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em .7em; }\n/* IE7-/Win - Fix extra vertical space in lists */\n.ui-accordion a { zoom: 1; }\n.ui-accordion-icons .ui-accordion-header a { padding-left: 2.2em; }\n.ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; }\n.ui-accordion .ui-accordion-content { padding: 1em 2.2em; border-top: 0; margin-top: -2px; position: relative; top: 1px; margin-bottom: 2px; overflow: auto; display: none; zoom: 1; }\n.ui-accordion .ui-accordion-content-active { display: block; }/* Autocomplete\n----------------------------------*/\n.ui-autocomplete { position: absolute; cursor: default; }\t\n.ui-autocomplete-loading { background: white url('images/ui-anim_basic_16x16.gif') right center no-repeat; }\n\n/* workarounds */\n* html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */\n\n/* Menu\n----------------------------------*/\n.ui-menu {\n\tlist-style:none;\n\tpadding: 2px;\n\tmargin: 0;\n\tdisplay:block;\n}\n.ui-menu .ui-menu {\n\tmargin-top: -3px;\n}\n.ui-menu .ui-menu-item {\n\tmargin:0;\n\tpadding: 0;\n\tzoom: 1;\n\tfloat: left;\n\tclear: left;\n\twidth: 100%;\n}\n.ui-menu .ui-menu-item a {\n\ttext-decoration:none;\n\tdisplay:block;\n\tpadding:.2em .4em;\n\tline-height:1.5;\n\tzoom:1;\n}\n.ui-menu .ui-menu-item a.ui-state-hover,\n.ui-menu .ui-menu-item a.ui-state-active {\n\tfont-weight: normal;\n\tmargin: -1px;\n}\n/* Button\n----------------------------------*/\n\n.ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */\n.ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */\nbutton.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */\n.ui-button-icons-only { width: 3.4em; } \nbutton.ui-button-icons-only { width: 3.7em; } \n\n/*button text element */\n.ui-button .ui-button-text { display: block; line-height: 1.4;  }\n.ui-button-text-only .ui-button-text { padding: .4em 1em; }\n.ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; }\n.ui-button-text-icon .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; }\n.ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; }\n/* no icon support for input elements, provide padding by default */\ninput.ui-button { padding: .4em 1em; }\n\n/*button icon element(s) */\n.ui-button-icon-only .ui-icon, .ui-button-text-icon .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; }\n.ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; }\n.ui-button-text-icon .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; }\n.ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; }\n\n/*button sets*/\n.ui-buttonset { margin-right: 7px; }\n.ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; }\n\n/* workarounds */\nbutton.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */\n\n\n\n\n\n/* Dialog\n----------------------------------*/\n.ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; }\n.ui-dialog .ui-dialog-titlebar { padding: .5em 1em .3em; position: relative;  }\n.ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .2em 0; } \n.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; }\n.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; }\n.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; }\n.ui-dialog .ui-dialog-content { border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; }\n.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; }\n.ui-dialog .ui-dialog-buttonpane button { float: right; margin: .5em .4em .5em 0; cursor: pointer; padding: .2em .6em .3em .6em; line-height: 1.4em; width:auto; overflow:visible; }\n.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; }\n.ui-draggable .ui-dialog-titlebar { cursor: move; }\n/* Slider\n----------------------------------*/\n.ui-slider { position: relative; text-align: left; }\n.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }\n.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; }\n\n.ui-slider-horizontal { height: .8em; }\n.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }\n.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }\n.ui-slider-horizontal .ui-slider-range-min { left: 0; }\n.ui-slider-horizontal .ui-slider-range-max { right: 0; }\n\n.ui-slider-vertical { width: .8em; height: 100px; }\n.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }\n.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }\n.ui-slider-vertical .ui-slider-range-min { bottom: 0; }\n.ui-slider-vertical .ui-slider-range-max { top: 0; }/* Tabs\n----------------------------------*/\n.ui-tabs { position: relative; padding: .2em; zoom: 1; } /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as \"fixed\") */\n.ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; }\n.ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap; }\n.ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; }\n.ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; }\n.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; }\n.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */\n.ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; }\n.ui-tabs .ui-tabs-hide { display: none !important; }\n/* Datepicker\n----------------------------------*/\n.ui-datepicker { width: 17em; padding: .2em .2em 0; }\n.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; }\n.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; }\n.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; }\n.ui-datepicker .ui-datepicker-prev { left:2px; }\n.ui-datepicker .ui-datepicker-next { right:2px; }\n.ui-datepicker .ui-datepicker-prev-hover { left:1px; }\n.ui-datepicker .ui-datepicker-next-hover { right:1px; }\n.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px;  }\n.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; }\n.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; }\n.ui-datepicker select.ui-datepicker-month-year {width: 100%;}\n.ui-datepicker select.ui-datepicker-month, \n.ui-datepicker select.ui-datepicker-year { width: 49%;}\n.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; }\n.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0;  }\n.ui-datepicker td { border: 0; padding: 1px; }\n.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; }\n.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; }\n.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }\n.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; }\n\n/* with multiple calendars */\n.ui-datepicker.ui-datepicker-multi { width:auto; }\n.ui-datepicker-multi .ui-datepicker-group { float:left; }\n.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; }\n.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; }\n.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; }\n.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; }\n.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; }\n.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; }\n.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; }\n.ui-datepicker-row-break { clear:both; width:100%; }\n\n/* RTL support */\n.ui-datepicker-rtl { direction: rtl; }\n.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; }\n.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; }\n.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; }\n.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; }\n.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; }\n.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; }\n.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; }\n.ui-datepicker-rtl .ui-datepicker-group { float:right; }\n.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; }\n.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; }\n\n/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */\n.ui-datepicker-cover {\n    display: none; /*sorry for IE5*/\n    display/**/: block; /*sorry for IE5*/\n    position: absolute; /*must have*/\n    z-index: -1; /*must have*/\n    filter: mask(); /*must have*/\n    top: -4px; /*must have*/\n    left: -4px; /*must have*/\n    width: 200px; /*must have*/\n    height: 200px; /*must have*/\n}/* Progressbar\n----------------------------------*/\n.ui-progressbar { height:2em; text-align: left; }\n.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }"
  },
  {
    "path": "js/admin/spectrum/spectrum.css",
    "content": "/***\nSpectrum Colorpicker v1.8.0\nhttps://github.com/bgrins/spectrum\nAuthor: Brian Grinstead\nLicense: MIT\n***/\n\n.sp-container {\n    position:absolute;\n    top:0;\n    left:0;\n    display:inline-block;\n    *display: inline;\n    *zoom: 1;\n    /* https://github.com/bgrins/spectrum/issues/40 */\n    z-index: 9999994;\n    overflow: hidden;\n}\n.sp-container.sp-flat {\n    position: relative;\n}\n\n/* Fix for * { box-sizing: border-box; } */\n.sp-container,\n.sp-container * {\n    -webkit-box-sizing: content-box;\n       -moz-box-sizing: content-box;\n            box-sizing: content-box;\n}\n\n/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */\n.sp-top {\n  position:relative;\n  width: 100%;\n  display:inline-block;\n}\n.sp-top-inner {\n   position:absolute;\n   top:0;\n   left:0;\n   bottom:0;\n   right:0;\n}\n.sp-color {\n    position: absolute;\n    top:0;\n    left:0;\n    bottom:0;\n    right:20%;\n}\n.sp-hue {\n    position: absolute;\n    top:0;\n    right:0;\n    bottom:0;\n    left:84%;\n    height: 100%;\n}\n\n.sp-clear-enabled .sp-hue {\n    top:33px;\n    height: 77.5%;\n}\n\n.sp-fill {\n    padding-top: 80%;\n}\n.sp-sat, .sp-val {\n    position: absolute;\n    top:0;\n    left:0;\n    right:0;\n    bottom:0;\n}\n\n.sp-alpha-enabled .sp-top {\n    margin-bottom: 18px;\n}\n.sp-alpha-enabled .sp-alpha {\n    display: block;\n}\n.sp-alpha-handle {\n    position:absolute;\n    top:-4px;\n    bottom: -4px;\n    width: 6px;\n    left: 50%;\n    cursor: pointer;\n    border: 1px solid black;\n    background: white;\n    opacity: .8;\n}\n.sp-alpha {\n    display: none;\n    position: absolute;\n    bottom: -14px;\n    right: 0;\n    left: 0;\n    height: 8px;\n}\n.sp-alpha-inner {\n    border: solid 1px #333;\n}\n\n.sp-clear {\n    display: none;\n}\n\n.sp-clear.sp-clear-display {\n    background-position: center;\n}\n\n.sp-clear-enabled .sp-clear {\n    display: block;\n    position:absolute;\n    top:0px;\n    right:0;\n    bottom:0;\n    left:84%;\n    height: 28px;\n}\n\n/* Don't allow text selection */\n.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button  {\n    -webkit-user-select:none;\n    -moz-user-select: -moz-none;\n    -o-user-select:none;\n    user-select: none;\n}\n\n.sp-container.sp-input-disabled .sp-input-container {\n    display: none;\n}\n.sp-container.sp-buttons-disabled .sp-button-container {\n    display: none;\n}\n.sp-container.sp-palette-buttons-disabled .sp-palette-button-container {\n    display: none;\n}\n.sp-palette-only .sp-picker-container {\n    display: none;\n}\n.sp-palette-disabled .sp-palette-container {\n    display: none;\n}\n\n.sp-initial-disabled .sp-initial {\n    display: none;\n}\n\n\n/* Gradients for hue, saturation and value instead of images.  Not pretty... but it works */\n.sp-sat {\n    background-image: -webkit-gradient(linear,  0 0, 100% 0, from(#FFF), to(rgba(204, 154, 129, 0)));\n    background-image: -webkit-linear-gradient(left, #FFF, rgba(204, 154, 129, 0));\n    background-image: -moz-linear-gradient(left, #fff, rgba(204, 154, 129, 0));\n    background-image: -o-linear-gradient(left, #fff, rgba(204, 154, 129, 0));\n    background-image: -ms-linear-gradient(left, #fff, rgba(204, 154, 129, 0));\n    background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0));\n    -ms-filter: \"progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)\";\n    filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81');\n}\n.sp-val {\n    background-image: -webkit-gradient(linear, 0 100%, 0 0, from(#000000), to(rgba(204, 154, 129, 0)));\n    background-image: -webkit-linear-gradient(bottom, #000000, rgba(204, 154, 129, 0));\n    background-image: -moz-linear-gradient(bottom, #000, rgba(204, 154, 129, 0));\n    background-image: -o-linear-gradient(bottom, #000, rgba(204, 154, 129, 0));\n    background-image: -ms-linear-gradient(bottom, #000, rgba(204, 154, 129, 0));\n    background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0));\n    -ms-filter: \"progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)\";\n    filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000');\n}\n\n.sp-hue {\n    background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\n    background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\n    background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\n    background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000));\n    background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\n    background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\n}\n\n/* IE filters do not support multiple color stops.\n   Generate 6 divs, line them up, and do two color gradients for each.\n   Yes, really.\n */\n.sp-1 {\n    height:17%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00');\n}\n.sp-2 {\n    height:16%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00');\n}\n.sp-3 {\n    height:17%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff');\n}\n.sp-4 {\n    height:17%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff');\n}\n.sp-5 {\n    height:16%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff');\n}\n.sp-6 {\n    height:17%;\n    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000');\n}\n\n.sp-hidden {\n    display: none !important;\n}\n\n/* Clearfix hack */\n.sp-cf:before, .sp-cf:after { content: \"\"; display: table; }\n.sp-cf:after { clear: both; }\n.sp-cf { *zoom: 1; }\n\n/* Mobile devices, make hue slider bigger so it is easier to slide */\n@media (max-device-width: 480px) {\n    .sp-color { right: 40%; }\n    .sp-hue { left: 63%; }\n    .sp-fill { padding-top: 60%; }\n}\n.sp-dragger {\n   border-radius: 5px;\n   height: 5px;\n   width: 5px;\n   border: 1px solid #fff;\n   background: #000;\n   cursor: pointer;\n   position:absolute;\n   top:0;\n   left: 0;\n}\n.sp-slider {\n    position: absolute;\n    top:0;\n    cursor:pointer;\n    height: 3px;\n    left: -1px;\n    right: -1px;\n    border: 1px solid #000;\n    background: white;\n    opacity: .8;\n}\n\n/*\nTheme authors:\nHere are the basic themeable display options (colors, fonts, global widths).\nSee http://bgrins.github.io/spectrum/themes/ for instructions.\n*/\n\n.sp-container {\n    border-radius: 0;\n    background-color: #f1f1f1;\n    border: solid 1px #ddd;\n    padding: 0;\n}\n.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear {\n    font: normal 12px \"Lucida Grande\", \"Lucida Sans Unicode\", \"Lucida Sans\", Geneva, Verdana, sans-serif;\n    -webkit-box-sizing: border-box;\n    -moz-box-sizing: border-box;\n    -ms-box-sizing: border-box;\n    box-sizing: border-box;\n}\n.sp-top {\n    margin-bottom: 3px;\n}\n.sp-color, .sp-hue, .sp-clear {\n    border: solid 1px #666;\n}\n\n/* Input */\n.sp-input-container {\n    float:right;\n    width: 100px;\n    margin-bottom: 4px;\n}\n.sp-initial-disabled  .sp-input-container {\n    width: 100%;\n}\n.sp-input {\n   font-size: 12px !important;\n   border: 1px inset;\n   padding: 4px 5px;\n   margin: 0;\n   width: 100%;\n   background:transparent;\n   border-radius: 3px;\n   color: #222;\n}\n.sp-input:focus  {\n    border: 1px solid orange;\n}\n.sp-input.sp-validation-error {\n    border: 1px solid red;\n    background: #fdd;\n}\n.sp-picker-container , .sp-palette-container {\n    float:left;\n    position: relative;\n    padding: 10px;\n    padding-bottom: 300px;\n    margin-bottom: -290px;\n}\n.sp-picker-container {\n    width: 172px;\n    border-left: solid 1px #fff;\n}\n\n/* Palettes */\n.sp-palette-container {\n    border-right: solid 1px #ccc;\n}\n\n.sp-palette-only .sp-palette-container {\n    border: 0;\n}\n\n.sp-palette .sp-thumb-el {\n    display: block;\n    position:relative;\n    float:left;\n    width: 24px;\n    height: 15px;\n    margin: 3px;\n    cursor: pointer;\n    border:solid 2px transparent;\n}\n.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active {\n    border-color: orange;\n}\n.sp-thumb-el {\n    position:relative;\n}\n\n/* Initial */\n.sp-initial {\n    float: left;\n    border: solid 1px #333;\n}\n.sp-initial span {\n    width: 30px;\n    height: 25px;\n    border:none;\n    display:block;\n    float:left;\n    margin:0;\n}\n\n.sp-initial .sp-clear-display {\n    background-position: center;\n}\n\n/* Buttons */\n.sp-palette-button-container,\n.sp-button-container {\n    float: right;\n}\n\n/* Replacer (the little preview div that shows up instead of the <input>) */\n.sp-replacer {\n    margin:0;\n    overflow:hidden;\n    cursor:pointer;\n    padding: 4px;\n    display:inline-block;\n    *zoom: 1;\n    *display: inline;\n    border: solid 1px #ddd;\n    /*background: #eee;*/\n    color: #333;\n    vertical-align: middle;\n}\n.sp-replacer:hover, .sp-replacer.sp-active {\n    border-color: #ddd;\n    color: #111;\n}\n.sp-replacer.sp-disabled {\n    cursor:default;\n    border-color: silver;\n    color: silver;\n}\n.sp-dd {\n    padding: 2px 0;\n    height: 16px;\n    line-height: 16px;\n    float:left;\n    font-size:10px;\n}\n.sp-preview {\n    position:relative;\n    width:25px;\n    height: 20px;\n    border: solid 1px #222;\n    margin-right: 5px;\n    float:left;\n    z-index: 0;\n}\n\n.sp-palette {\n    *width: 220px;\n    max-width: 220px;\n}\n.sp-palette .sp-thumb-el {\n    width:16px;\n    height: 16px;\n    margin:2px 1px;\n    border: solid 1px #d0d0d0;\n}\n\n.sp-container {\n    padding-bottom:0;\n}\n\n\n/* Buttons: http://hellohappy.org/css3-buttons/ */\n.sp-container button {\n  background-color: #eeeeee;\n  background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc);\n  background-image: -moz-linear-gradient(top, #eeeeee, #cccccc);\n  background-image: -ms-linear-gradient(top, #eeeeee, #cccccc);\n  background-image: -o-linear-gradient(top, #eeeeee, #cccccc);\n  background-image: linear-gradient(to bottom, #eeeeee, #cccccc);\n  border: 1px solid #ccc;\n  border-bottom: 1px solid #bbb;\n  border-radius: 3px;\n  color: #333;\n  font-size: 14px;\n  line-height: 1;\n  padding: 5px 4px;\n  text-align: center;\n  text-shadow: 0 1px 0 #eee;\n  vertical-align: middle;\n}\n.sp-container button:hover {\n    background-color: #dddddd;\n    background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb);\n    background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb);\n    background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb);\n    background-image: -o-linear-gradient(top, #dddddd, #bbbbbb);\n    background-image: linear-gradient(to bottom, #dddddd, #bbbbbb);\n    border: 1px solid #bbb;\n    border-bottom: 1px solid #999;\n    cursor: pointer;\n    text-shadow: 0 1px 0 #ddd;\n}\n.sp-container button:active {\n    border: 1px solid #aaa;\n    border-bottom: 1px solid #888;\n    -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;\n    -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;\n    -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;\n    -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;\n    box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;\n}\n.sp-cancel {\n    font-size: 11px;\n    color: #d93f3f !important;\n    margin:0;\n    padding:2px;\n    margin-right: 5px;\n    vertical-align: middle;\n    text-decoration:none;\n\n}\n.sp-cancel:hover {\n    color: #d93f3f !important;\n    text-decoration: underline;\n}\n\n\n.sp-palette span:hover, .sp-palette span.sp-thumb-active {\n    border-color: #000;\n}\n\n.sp-preview, .sp-alpha, .sp-thumb-el {\n    position:relative;\n    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);\n}\n.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner {\n    display:block;\n    position:absolute;\n    top:0;left:0;bottom:0;right:0;\n}\n\n.sp-palette .sp-thumb-inner {\n    background-position: 50% 50%;\n    background-repeat: no-repeat;\n}\n\n.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner {\n    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=);\n}\n\n.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner {\n    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=);\n}\n\n.sp-clear-display {\n    background-repeat:no-repeat;\n    background-position: center;\n    background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==);\n}\n\n.sp-cancel {\n    line-height: 28px;\n}\n\nbutton.sp-choose {\n    color: #555;\n    border-color: #ccc;\n    background: #f7f7f7;\n    -webkit-box-shadow: 0 1px 0 #ccc;\n    box-shadow: 0 1px 0 #ccc;\n    vertical-align: top;\n    display: inline-block;\n    text-decoration: none;\n    font-size: 13px;\n    line-height: 26px;\n    height: 28px;\n    margin: 0;\n    padding: 0 10px 1px;\n    cursor: pointer;\n    border-width: 1px;\n    border-style: solid;\n    -webkit-appearance: none;\n    -webkit-border-radius: 3px;\n    border-radius: 3px;\n    white-space: nowrap;\n    -webkit-box-sizing: border-box;\n    -moz-box-sizing: border-box;\n    box-sizing: border-box;\n}\n"
  },
  {
    "path": "js/admin/spectrum/spectrum.js",
    "content": "// Spectrum Colorpicker v1.8.0\n// https://github.com/bgrins/spectrum\n// Author: Brian Grinstead\n// License: MIT\n\n(function (factory) {\n    \"use strict\";\n\n    if (typeof define === 'function' && define.amd) { // AMD\n        define(['jquery'], factory);\n    }\n    else if (typeof exports == \"object\" && typeof module == \"object\") { // CommonJS\n        module.exports = factory(require('jquery'));\n    }\n    else { // Browser\n        factory(jQuery);\n    }\n})(function($, undefined) {\n    \"use strict\";\n\n    var defaultOpts = {\n\n        // Callbacks\n        beforeShow: noop,\n        move: noop,\n        change: noop,\n        show: noop,\n        hide: noop,\n\n        // Options\n        color: false,\n        flat: false,\n        showInput: false,\n        allowEmpty: false,\n        showButtons: true,\n        clickoutFiresChange: true,\n        showInitial: false,\n        showPalette: false,\n        showPaletteOnly: false,\n        hideAfterPaletteSelect: false,\n        togglePaletteOnly: false,\n        showSelectionPalette: true,\n        localStorageKey: false,\n        appendTo: \"body\",\n        maxSelectionSize: 7,\n        cancelText: \"cancel\",\n        chooseText: \"choose\",\n        togglePaletteMoreText: \"more\",\n        togglePaletteLessText: \"less\",\n        clearText: \"Clear Color Selection\",\n        noColorSelectedText: \"No Color Selected\",\n        preferredFormat: false,\n        className: \"\", // Deprecated - use containerClassName and replacerClassName instead.\n        containerClassName: \"\",\n        replacerClassName: \"\",\n        showAlpha: false,\n        theme: \"sp-light\",\n        palette: [[\"#ffffff\", \"#000000\", \"#ff0000\", \"#ff8000\", \"#ffff00\", \"#008000\", \"#0000ff\", \"#4b0082\", \"#9400d3\"]],\n        selectionPalette: [],\n        disabled: false,\n        offset: null\n    },\n    spectrums = [],\n    IE = !!/msie/i.exec( window.navigator.userAgent ),\n    rgbaSupport = (function() {\n        function contains( str, substr ) {\n            return !!~('' + str).indexOf(substr);\n        }\n\n        var elem = document.createElement('div');\n        var style = elem.style;\n        style.cssText = 'background-color:rgba(0,0,0,.5)';\n        return contains(style.backgroundColor, 'rgba') || contains(style.backgroundColor, 'hsla');\n    })(),\n    replaceInput = [\n        \"<div class='sp-replacer'>\",\n            \"<div class='sp-preview'><div class='sp-preview-inner'></div></div>\",\n            \"<div class='sp-dd'>&#9660;</div>\",\n        \"</div>\"\n    ].join(''),\n    markup = (function () {\n\n        // IE does not support gradients with multiple stops, so we need to simulate\n        //  that for the rainbow slider with 8 divs that each have a single gradient\n        var gradientFix = \"\";\n        if (IE) {\n            for (var i = 1; i <= 6; i++) {\n                gradientFix += \"<div class='sp-\" + i + \"'></div>\";\n            }\n        }\n\n        return [\n            \"<div class='sp-container sp-hidden'>\",\n                \"<div class='sp-palette-container'>\",\n                    \"<div class='sp-palette sp-thumb sp-cf'></div>\",\n                    \"<div class='sp-palette-button-container sp-cf'>\",\n                        \"<button type='button' class='sp-palette-toggle'></button>\",\n                    \"</div>\",\n                \"</div>\",\n                \"<div class='sp-picker-container'>\",\n                    \"<div class='sp-top sp-cf'>\",\n                        \"<div class='sp-fill'></div>\",\n                        \"<div class='sp-top-inner'>\",\n                            \"<div class='sp-color'>\",\n                                \"<div class='sp-sat'>\",\n                                    \"<div class='sp-val'>\",\n                                        \"<div class='sp-dragger'></div>\",\n                                    \"</div>\",\n                                \"</div>\",\n                            \"</div>\",\n                            \"<div class='sp-clear sp-clear-display'>\",\n                            \"</div>\",\n                            \"<div class='sp-hue'>\",\n                                \"<div class='sp-slider'></div>\",\n                                gradientFix,\n                            \"</div>\",\n                        \"</div>\",\n                        \"<div class='sp-alpha'><div class='sp-alpha-inner'><div class='sp-alpha-handle'></div></div></div>\",\n                    \"</div>\",\n                    \"<div class='sp-input-container sp-cf'>\",\n                        \"<input class='sp-input' type='text' spellcheck='false'  />\",\n                    \"</div>\",\n                    \"<div class='sp-initial sp-thumb sp-cf'></div>\",\n                    \"<div class='sp-button-container sp-cf'>\",\n                        \"<a class='sp-cancel' href='#'></a>\",\n                        \"<button type='button' class='sp-choose'></button>\",\n                    \"</div>\",\n                \"</div>\",\n            \"</div>\"\n        ].join(\"\");\n    })();\n\n    function paletteTemplate (p, color, className, opts) {\n        var html = [];\n        for (var i = 0; i < p.length; i++) {\n            var current = p[i];\n            if(current) {\n                var tiny = tinycolor(current);\n                var c = tiny.toHsl().l < 0.5 ? \"sp-thumb-el sp-thumb-dark\" : \"sp-thumb-el sp-thumb-light\";\n                c += (tinycolor.equals(color, current)) ? \" sp-thumb-active\" : \"\";\n                var formattedString = tiny.toString(opts.preferredFormat || \"rgb\");\n                var swatchStyle = rgbaSupport ? (\"background-color:\" + tiny.toRgbString()) : \"filter:\" + tiny.toFilter();\n                html.push('<span title=\"' + formattedString + '\" data-color=\"' + tiny.toRgbString() + '\" class=\"' + c + '\"><span class=\"sp-thumb-inner\" style=\"' + swatchStyle + ';\" /></span>');\n            } else {\n                var cls = 'sp-clear-display';\n                html.push($('<div />')\n                    .append($('<span data-color=\"\" style=\"background-color:transparent;\" class=\"' + cls + '\"></span>')\n                        .attr('title', opts.noColorSelectedText)\n                    )\n                    .html()\n                );\n            }\n        }\n        return \"<div class='sp-cf \" + className + \"'>\" + html.join('') + \"</div>\";\n    }\n\n    function hideAll() {\n        for (var i = 0; i < spectrums.length; i++) {\n            if (spectrums[i]) {\n                spectrums[i].hide();\n            }\n        }\n    }\n\n    function instanceOptions(o, callbackContext) {\n        var opts = $.extend({}, defaultOpts, o);\n        opts.callbacks = {\n            'move': bind(opts.move, callbackContext),\n            'change': bind(opts.change, callbackContext),\n            'show': bind(opts.show, callbackContext),\n            'hide': bind(opts.hide, callbackContext),\n            'beforeShow': bind(opts.beforeShow, callbackContext)\n        };\n\n        return opts;\n    }\n\n    function spectrum(element, o) {\n\n        var opts = instanceOptions(o, element),\n            flat = opts.flat,\n            showSelectionPalette = opts.showSelectionPalette,\n            localStorageKey = opts.localStorageKey,\n            theme = opts.theme,\n            callbacks = opts.callbacks,\n            resize = throttle(reflow, 10),\n            visible = false,\n            isDragging = false,\n            dragWidth = 0,\n            dragHeight = 0,\n            dragHelperHeight = 0,\n            slideHeight = 0,\n            slideWidth = 0,\n            alphaWidth = 0,\n            alphaSlideHelperWidth = 0,\n            slideHelperHeight = 0,\n            currentHue = 0,\n            currentSaturation = 0,\n            currentValue = 0,\n            currentAlpha = 1,\n            palette = [],\n            paletteArray = [],\n            paletteLookup = {},\n            selectionPalette = opts.selectionPalette.slice(0),\n            maxSelectionSize = opts.maxSelectionSize,\n            draggingClass = \"sp-dragging\",\n            shiftMovementDirection = null;\n\n        var doc = element.ownerDocument,\n            body = doc.body,\n            boundElement = $(element),\n            disabled = false,\n            container = $(markup, doc).addClass(theme),\n            pickerContainer = container.find(\".sp-picker-container\"),\n            dragger = container.find(\".sp-color\"),\n            dragHelper = container.find(\".sp-dragger\"),\n            slider = container.find(\".sp-hue\"),\n            slideHelper = container.find(\".sp-slider\"),\n            alphaSliderInner = container.find(\".sp-alpha-inner\"),\n            alphaSlider = container.find(\".sp-alpha\"),\n            alphaSlideHelper = container.find(\".sp-alpha-handle\"),\n            textInput = container.find(\".sp-input\"),\n            paletteContainer = container.find(\".sp-palette\"),\n            initialColorContainer = container.find(\".sp-initial\"),\n            cancelButton = container.find(\".sp-cancel\"),\n            clearButton = container.find(\".sp-clear\"),\n            chooseButton = container.find(\".sp-choose\"),\n            toggleButton = container.find(\".sp-palette-toggle\"),\n            isInput = boundElement.is(\"input\"),\n            isInputTypeColor = isInput && boundElement.attr(\"type\") === \"color\" && inputTypeColorSupport(),\n            shouldReplace = isInput && !flat,\n            replacer = (shouldReplace) ? $(replaceInput).addClass(theme).addClass(opts.className).addClass(opts.replacerClassName) : $([]),\n            offsetElement = (shouldReplace) ? replacer : boundElement,\n            previewElement = replacer.find(\".sp-preview-inner\"),\n            initialColor = opts.color || (isInput && boundElement.val()),\n            colorOnShow = false,\n            currentPreferredFormat = opts.preferredFormat,\n            clickoutFiresChange = !opts.showButtons || opts.clickoutFiresChange,\n            isEmpty = !initialColor,\n            allowEmpty = opts.allowEmpty && !isInputTypeColor;\n\n        function applyOptions() {\n\n            if (opts.showPaletteOnly) {\n                opts.showPalette = true;\n            }\n\n            toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText);\n\n            if (opts.palette) {\n                palette = opts.palette.slice(0);\n                paletteArray = $.isArray(palette[0]) ? palette : [palette];\n                paletteLookup = {};\n                for (var i = 0; i < paletteArray.length; i++) {\n                    for (var j = 0; j < paletteArray[i].length; j++) {\n                        var rgb = tinycolor(paletteArray[i][j]).toRgbString();\n                        paletteLookup[rgb] = true;\n                    }\n                }\n            }\n\n            container.toggleClass(\"sp-flat\", flat);\n            container.toggleClass(\"sp-input-disabled\", !opts.showInput);\n            container.toggleClass(\"sp-alpha-enabled\", opts.showAlpha);\n            container.toggleClass(\"sp-clear-enabled\", allowEmpty);\n            container.toggleClass(\"sp-buttons-disabled\", !opts.showButtons);\n            container.toggleClass(\"sp-palette-buttons-disabled\", !opts.togglePaletteOnly);\n            container.toggleClass(\"sp-palette-disabled\", !opts.showPalette);\n            container.toggleClass(\"sp-palette-only\", opts.showPaletteOnly);\n            container.toggleClass(\"sp-initial-disabled\", !opts.showInitial);\n            container.addClass(opts.className).addClass(opts.containerClassName);\n\n            reflow();\n        }\n\n        function initialize() {\n\n            if (IE) {\n                container.find(\"*:not(input)\").attr(\"unselectable\", \"on\");\n            }\n\n            applyOptions();\n\n            if (shouldReplace) {\n                boundElement.after(replacer).hide();\n            }\n\n            if (!allowEmpty) {\n                clearButton.hide();\n            }\n\n            if (flat) {\n                boundElement.after(container).hide();\n            }\n            else {\n\n                var appendTo = opts.appendTo === \"parent\" ? boundElement.parent() : $(opts.appendTo);\n                if (appendTo.length !== 1) {\n                    appendTo = $(\"body\");\n                }\n\n                appendTo.append(container);\n            }\n\n            updateSelectionPaletteFromStorage();\n\n            offsetElement.bind(\"click.spectrum touchstart.spectrum\", function (e) {\n                if (!disabled) {\n                    toggle();\n                }\n\n                e.stopPropagation();\n\n                if (!$(e.target).is(\"input\")) {\n                    e.preventDefault();\n                }\n            });\n\n            if(boundElement.is(\":disabled\") || (opts.disabled === true)) {\n                disable();\n            }\n\n            // Prevent clicks from bubbling up to document.  This would cause it to be hidden.\n            container.click(stopPropagation);\n\n            // Handle user typed input\n            textInput.change(setFromTextInput);\n            textInput.bind(\"paste\", function () {\n                setTimeout(setFromTextInput, 1);\n            });\n            textInput.keydown(function (e) { if (e.keyCode == 13) { setFromTextInput(); } });\n\n            cancelButton.text(opts.cancelText);\n            cancelButton.bind(\"click.spectrum\", function (e) {\n                e.stopPropagation();\n                e.preventDefault();\n                revert();\n                hide();\n            });\n\n            clearButton.attr(\"title\", opts.clearText);\n            clearButton.bind(\"click.spectrum\", function (e) {\n                e.stopPropagation();\n                e.preventDefault();\n                isEmpty = true;\n                move();\n\n                if(flat) {\n                    //for the flat style, this is a change event\n                    updateOriginalInput(true);\n                }\n            });\n\n            chooseButton.text(opts.chooseText);\n            chooseButton.bind(\"click.spectrum\", function (e) {\n                e.stopPropagation();\n                e.preventDefault();\n\n                if (IE && textInput.is(\":focus\")) {\n                    textInput.trigger('change');\n                }\n\n                if (isValid()) {\n                    updateOriginalInput(true);\n                    hide();\n                }\n            });\n\n            toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText);\n            toggleButton.bind(\"click.spectrum\", function (e) {\n                e.stopPropagation();\n                e.preventDefault();\n\n                opts.showPaletteOnly = !opts.showPaletteOnly;\n\n                // To make sure the Picker area is drawn on the right, next to the\n                // Palette area (and not below the palette), first move the Palette\n                // to the left to make space for the picker, plus 5px extra.\n                // The 'applyOptions' function puts the whole container back into place\n                // and takes care of the button-text and the sp-palette-only CSS class.\n                if (!opts.showPaletteOnly && !flat) {\n                    container.css('left', '-=' + (pickerContainer.outerWidth(true) + 5));\n                }\n                applyOptions();\n            });\n\n            draggable(alphaSlider, function (dragX, dragY, e) {\n                currentAlpha = (dragX / alphaWidth);\n                isEmpty = false;\n                if (e.shiftKey) {\n                    currentAlpha = Math.round(currentAlpha * 10) / 10;\n                }\n\n                move();\n            }, dragStart, dragStop);\n\n            draggable(slider, function (dragX, dragY) {\n                currentHue = parseFloat(dragY / slideHeight);\n                isEmpty = false;\n                if (!opts.showAlpha) {\n                    currentAlpha = 1;\n                }\n                move();\n            }, dragStart, dragStop);\n\n            draggable(dragger, function (dragX, dragY, e) {\n\n                // shift+drag should snap the movement to either the x or y axis.\n                if (!e.shiftKey) {\n                    shiftMovementDirection = null;\n                }\n                else if (!shiftMovementDirection) {\n                    var oldDragX = currentSaturation * dragWidth;\n                    var oldDragY = dragHeight - (currentValue * dragHeight);\n                    var furtherFromX = Math.abs(dragX - oldDragX) > Math.abs(dragY - oldDragY);\n\n                    shiftMovementDirection = furtherFromX ? \"x\" : \"y\";\n                }\n\n                var setSaturation = !shiftMovementDirection || shiftMovementDirection === \"x\";\n                var setValue = !shiftMovementDirection || shiftMovementDirection === \"y\";\n\n                if (setSaturation) {\n                    currentSaturation = parseFloat(dragX / dragWidth);\n                }\n                if (setValue) {\n                    currentValue = parseFloat((dragHeight - dragY) / dragHeight);\n                }\n\n                isEmpty = false;\n                if (!opts.showAlpha) {\n                    currentAlpha = 1;\n                }\n\n                move();\n\n            }, dragStart, dragStop);\n\n            if (!!initialColor) {\n                set(initialColor);\n\n                // In case color was black - update the preview UI and set the format\n                // since the set function will not run (default color is black).\n                updateUI();\n                currentPreferredFormat = opts.preferredFormat || tinycolor(initialColor).format;\n\n                addColorToSelectionPalette(initialColor);\n            }\n            else {\n                updateUI();\n            }\n\n            if (flat) {\n                show();\n            }\n\n            function paletteElementClick(e) {\n                if (e.data && e.data.ignore) {\n                    set($(e.target).closest(\".sp-thumb-el\").data(\"color\"));\n                    move();\n                }\n                else {\n                    set($(e.target).closest(\".sp-thumb-el\").data(\"color\"));\n                    move();\n                    updateOriginalInput(true);\n                    if (opts.hideAfterPaletteSelect) {\n                      hide();\n                    }\n                }\n\n                return false;\n            }\n\n            var paletteEvent = IE ? \"mousedown.spectrum\" : \"click.spectrum touchstart.spectrum\";\n            paletteContainer.delegate(\".sp-thumb-el\", paletteEvent, paletteElementClick);\n            initialColorContainer.delegate(\".sp-thumb-el:nth-child(1)\", paletteEvent, { ignore: true }, paletteElementClick);\n        }\n\n        function updateSelectionPaletteFromStorage() {\n\n            if (localStorageKey && window.localStorage) {\n\n                // Migrate old palettes over to new format.  May want to remove this eventually.\n                try {\n                    var oldPalette = window.localStorage[localStorageKey].split(\",#\");\n                    if (oldPalette.length > 1) {\n                        delete window.localStorage[localStorageKey];\n                        $.each(oldPalette, function(i, c) {\n                             addColorToSelectionPalette(c);\n                        });\n                    }\n                }\n                catch(e) { }\n\n                try {\n                    selectionPalette = window.localStorage[localStorageKey].split(\";\");\n                }\n                catch (e) { }\n            }\n        }\n\n        function addColorToSelectionPalette(color) {\n            if (showSelectionPalette) {\n                var rgb = tinycolor(color).toRgbString();\n                if (!paletteLookup[rgb] && $.inArray(rgb, selectionPalette) === -1) {\n                    selectionPalette.push(rgb);\n                    while(selectionPalette.length > maxSelectionSize) {\n                        selectionPalette.shift();\n                    }\n                }\n\n                if (localStorageKey && window.localStorage) {\n                    try {\n                        window.localStorage[localStorageKey] = selectionPalette.join(\";\");\n                    }\n                    catch(e) { }\n                }\n            }\n        }\n\n        function getUniqueSelectionPalette() {\n            var unique = [];\n            if (opts.showPalette) {\n                for (var i = 0; i < selectionPalette.length; i++) {\n                    var rgb = tinycolor(selectionPalette[i]).toRgbString();\n\n                    if (!paletteLookup[rgb]) {\n                        unique.push(selectionPalette[i]);\n                    }\n                }\n            }\n\n            return unique.reverse().slice(0, opts.maxSelectionSize);\n        }\n\n        function drawPalette() {\n\n            var currentColor = get();\n\n            var html = $.map(paletteArray, function (palette, i) {\n                return paletteTemplate(palette, currentColor, \"sp-palette-row sp-palette-row-\" + i, opts);\n            });\n\n            updateSelectionPaletteFromStorage();\n\n            if (selectionPalette) {\n                html.push(paletteTemplate(getUniqueSelectionPalette(), currentColor, \"sp-palette-row sp-palette-row-selection\", opts));\n            }\n\n            paletteContainer.html(html.join(\"\"));\n        }\n\n        function drawInitial() {\n            if (opts.showInitial) {\n                var initial = colorOnShow;\n                var current = get();\n                initialColorContainer.html(paletteTemplate([initial, current], current, \"sp-palette-row-initial\", opts));\n            }\n        }\n\n        function dragStart() {\n            if (dragHeight <= 0 || dragWidth <= 0 || slideHeight <= 0) {\n                reflow();\n            }\n            isDragging = true;\n            container.addClass(draggingClass);\n            shiftMovementDirection = null;\n            boundElement.trigger('dragstart.spectrum', [ get() ]);\n        }\n\n        function dragStop() {\n            isDragging = false;\n            container.removeClass(draggingClass);\n            boundElement.trigger('dragstop.spectrum', [ get() ]);\n        }\n\n        function setFromTextInput() {\n\n            var value = textInput.val();\n\n            if ((value === null || value === \"\") && allowEmpty) {\n                set(null);\n                updateOriginalInput(true);\n            }\n            else {\n                var tiny = tinycolor(value);\n                if (tiny.isValid()) {\n                    set(tiny);\n                    updateOriginalInput(true);\n                }\n                else {\n                    textInput.addClass(\"sp-validation-error\");\n                }\n            }\n        }\n\n        function toggle() {\n            if (visible) {\n                hide();\n            }\n            else {\n                show();\n            }\n        }\n\n        function show() {\n            var event = $.Event('beforeShow.spectrum');\n\n            if (visible) {\n                reflow();\n                return;\n            }\n\n            boundElement.trigger(event, [ get() ]);\n\n            if (callbacks.beforeShow(get()) === false || event.isDefaultPrevented()) {\n                return;\n            }\n\n            hideAll();\n            visible = true;\n\n            $(doc).bind(\"keydown.spectrum\", onkeydown);\n            $(doc).bind(\"click.spectrum\", clickout);\n            $(window).bind(\"resize.spectrum\", resize);\n            replacer.addClass(\"sp-active\");\n            container.removeClass(\"sp-hidden\");\n\n            reflow();\n            updateUI();\n\n            colorOnShow = get();\n\n            drawInitial();\n            callbacks.show(colorOnShow);\n            boundElement.trigger('show.spectrum', [ colorOnShow ]);\n        }\n\n        function onkeydown(e) {\n            // Close on ESC\n            if (e.keyCode === 27) {\n                hide();\n            }\n        }\n\n        function clickout(e) {\n            // Return on right click.\n            if (e.button == 2) { return; }\n\n            // If a drag event was happening during the mouseup, don't hide\n            // on click.\n            if (isDragging) { return; }\n\n            if (clickoutFiresChange) {\n                updateOriginalInput(true);\n            }\n            else {\n                revert();\n            }\n            hide();\n        }\n\n        function hide() {\n            // Return if hiding is unnecessary\n            if (!visible || flat) { return; }\n            visible = false;\n\n            $(doc).unbind(\"keydown.spectrum\", onkeydown);\n            $(doc).unbind(\"click.spectrum\", clickout);\n            $(window).unbind(\"resize.spectrum\", resize);\n\n            replacer.removeClass(\"sp-active\");\n            container.addClass(\"sp-hidden\");\n\n            callbacks.hide(get());\n            boundElement.trigger('hide.spectrum', [ get() ]);\n        }\n\n        function revert() {\n            set(colorOnShow, true);\n        }\n\n        function set(color, ignoreFormatChange) {\n            if (tinycolor.equals(color, get())) {\n                // Update UI just in case a validation error needs\n                // to be cleared.\n                updateUI();\n                return;\n            }\n\n            var newColor, newHsv;\n            if (!color && allowEmpty) {\n                isEmpty = true;\n            } else {\n                isEmpty = false;\n                newColor = tinycolor(color);\n                newHsv = newColor.toHsv();\n\n                currentHue = (newHsv.h % 360) / 360;\n                currentSaturation = newHsv.s;\n                currentValue = newHsv.v;\n                currentAlpha = newHsv.a;\n            }\n            updateUI();\n\n            if (newColor && newColor.isValid() && !ignoreFormatChange) {\n                currentPreferredFormat = opts.preferredFormat || newColor.getFormat();\n            }\n        }\n\n        function get(opts) {\n            opts = opts || { };\n\n            if (allowEmpty && isEmpty) {\n                return null;\n            }\n\n            return tinycolor.fromRatio({\n                h: currentHue,\n                s: currentSaturation,\n                v: currentValue,\n                a: Math.round(currentAlpha * 100) / 100\n            }, { format: opts.format || currentPreferredFormat });\n        }\n\n        function isValid() {\n            return !textInput.hasClass(\"sp-validation-error\");\n        }\n\n        function move() {\n            updateUI();\n\n            callbacks.move(get());\n            boundElement.trigger('move.spectrum', [ get() ]);\n        }\n\n        function updateUI() {\n\n            textInput.removeClass(\"sp-validation-error\");\n\n            updateHelperLocations();\n\n            // Update dragger background color (gradients take care of saturation and value).\n            var flatColor = tinycolor.fromRatio({ h: currentHue, s: 1, v: 1 });\n            dragger.css(\"background-color\", flatColor.toHexString());\n\n            // Get a format that alpha will be included in (hex and names ignore alpha)\n            var format = currentPreferredFormat;\n            if (currentAlpha < 1 && !(currentAlpha === 0 && format === \"name\")) {\n                if (format === \"hex\" || format === \"hex3\" || format === \"hex6\" || format === \"name\") {\n                    format = \"rgb\";\n                }\n            }\n\n            var realColor = get({ format: format }),\n                displayColor = '';\n\n             //reset background info for preview element\n            previewElement.removeClass(\"sp-clear-display\");\n            previewElement.css('background-color', 'transparent');\n\n            if (!realColor && allowEmpty) {\n                // Update the replaced elements background with icon indicating no color selection\n                previewElement.addClass(\"sp-clear-display\");\n            }\n            else {\n                var realHex = realColor.toHexString(),\n                    realRgb = realColor.toRgbString();\n\n                // Update the replaced elements background color (with actual selected color)\n                if (rgbaSupport || realColor.alpha === 1) {\n                    previewElement.css(\"background-color\", realRgb);\n                }\n                else {\n                    previewElement.css(\"background-color\", \"transparent\");\n                    previewElement.css(\"filter\", realColor.toFilter());\n                }\n\n                if (opts.showAlpha) {\n                    var rgb = realColor.toRgb();\n                    rgb.a = 0;\n                    var realAlpha = tinycolor(rgb).toRgbString();\n                    var gradient = \"linear-gradient(left, \" + realAlpha + \", \" + realHex + \")\";\n\n                    if (IE) {\n                        alphaSliderInner.css(\"filter\", tinycolor(realAlpha).toFilter({ gradientType: 1 }, realHex));\n                    }\n                    else {\n                        alphaSliderInner.css(\"background\", \"-webkit-\" + gradient);\n                        alphaSliderInner.css(\"background\", \"-moz-\" + gradient);\n                        alphaSliderInner.css(\"background\", \"-ms-\" + gradient);\n                        // Use current syntax gradient on unprefixed property.\n                        alphaSliderInner.css(\"background\",\n                            \"linear-gradient(to right, \" + realAlpha + \", \" + realHex + \")\");\n                    }\n                }\n\n                displayColor = realColor.toString(format);\n            }\n\n            // Update the text entry input as it changes happen\n            if (opts.showInput) {\n                textInput.val(displayColor);\n            }\n\n            if (opts.showPalette) {\n                drawPalette();\n            }\n\n            drawInitial();\n        }\n\n        function updateHelperLocations() {\n            var s = currentSaturation;\n            var v = currentValue;\n\n            if(allowEmpty && isEmpty) {\n                //if selected color is empty, hide the helpers\n                alphaSlideHelper.hide();\n                slideHelper.hide();\n                dragHelper.hide();\n            }\n            else {\n                //make sure helpers are visible\n                alphaSlideHelper.show();\n                slideHelper.show();\n                dragHelper.show();\n\n                // Where to show the little circle in that displays your current selected color\n                var dragX = s * dragWidth;\n                var dragY = dragHeight - (v * dragHeight);\n                dragX = Math.max(\n                    -dragHelperHeight,\n                    Math.min(dragWidth - dragHelperHeight, dragX - dragHelperHeight)\n                );\n                dragY = Math.max(\n                    -dragHelperHeight,\n                    Math.min(dragHeight - dragHelperHeight, dragY - dragHelperHeight)\n                );\n                dragHelper.css({\n                    \"top\": dragY + \"px\",\n                    \"left\": dragX + \"px\"\n                });\n\n                var alphaX = currentAlpha * alphaWidth;\n                alphaSlideHelper.css({\n                    \"left\": (alphaX - (alphaSlideHelperWidth / 2)) + \"px\"\n                });\n\n                // Where to show the bar that displays your current selected hue\n                var slideY = (currentHue) * slideHeight;\n                slideHelper.css({\n                    \"top\": (slideY - slideHelperHeight) + \"px\"\n                });\n            }\n        }\n\n        function updateOriginalInput(fireCallback) {\n            var color = get(),\n                displayColor = '',\n                hasChanged = !tinycolor.equals(color, colorOnShow);\n\n            if (color) {\n                displayColor = color.toString(currentPreferredFormat);\n                // Update the selection palette with the current color\n                addColorToSelectionPalette(color);\n            }\n\n            if (isInput) {\n                boundElement.val(displayColor);\n            }\n\n            if (fireCallback && hasChanged) {\n                callbacks.change(color);\n                boundElement.trigger('change', [ color ]);\n            }\n        }\n\n        function reflow() {\n            if (!visible) {\n                return; // Calculations would be useless and wouldn't be reliable anyways\n            }\n            dragWidth = dragger.width();\n            dragHeight = dragger.height();\n            dragHelperHeight = dragHelper.height();\n            slideWidth = slider.width();\n            slideHeight = slider.height();\n            slideHelperHeight = slideHelper.height();\n            alphaWidth = alphaSlider.width();\n            alphaSlideHelperWidth = alphaSlideHelper.width();\n\n            if (!flat) {\n                container.css(\"position\", \"absolute\");\n                if (opts.offset) {\n                    container.offset(opts.offset);\n                } else {\n                    container.offset(getOffset(container, offsetElement));\n                }\n            }\n\n            updateHelperLocations();\n\n            if (opts.showPalette) {\n                drawPalette();\n            }\n\n            boundElement.trigger('reflow.spectrum');\n        }\n\n        function destroy() {\n            boundElement.show();\n            offsetElement.unbind(\"click.spectrum touchstart.spectrum\");\n            container.remove();\n            replacer.remove();\n            spectrums[spect.id] = null;\n        }\n\n        function option(optionName, optionValue) {\n            if (optionName === undefined) {\n                return $.extend({}, opts);\n            }\n            if (optionValue === undefined) {\n                return opts[optionName];\n            }\n\n            opts[optionName] = optionValue;\n\n            if (optionName === \"preferredFormat\") {\n                currentPreferredFormat = opts.preferredFormat;\n            }\n            applyOptions();\n        }\n\n        function enable() {\n            disabled = false;\n            boundElement.attr(\"disabled\", false);\n            offsetElement.removeClass(\"sp-disabled\");\n        }\n\n        function disable() {\n            hide();\n            disabled = true;\n            boundElement.attr(\"disabled\", true);\n            offsetElement.addClass(\"sp-disabled\");\n        }\n\n        function setOffset(coord) {\n            opts.offset = coord;\n            reflow();\n        }\n\n        initialize();\n\n        var spect = {\n            show: show,\n            hide: hide,\n            toggle: toggle,\n            reflow: reflow,\n            option: option,\n            enable: enable,\n            disable: disable,\n            offset: setOffset,\n            set: function (c) {\n                set(c);\n                updateOriginalInput();\n            },\n            get: get,\n            destroy: destroy,\n            container: container\n        };\n\n        spect.id = spectrums.push(spect) - 1;\n\n        return spect;\n    }\n\n    /**\n    * checkOffset - get the offset below/above and left/right element depending on screen position\n    * Thanks https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js\n    */\n    function getOffset(picker, input) {\n        var extraY = 0;\n        var dpWidth = picker.outerWidth();\n        var dpHeight = picker.outerHeight();\n        var inputHeight = input.outerHeight();\n        var doc = picker[0].ownerDocument;\n        var docElem = doc.documentElement;\n        var viewWidth = docElem.clientWidth + $(doc).scrollLeft();\n        var viewHeight = docElem.clientHeight + $(doc).scrollTop();\n        var offset = input.offset();\n        offset.top += inputHeight;\n\n        offset.left -=\n            Math.min(offset.left, (offset.left + dpWidth > viewWidth && viewWidth > dpWidth) ?\n            Math.abs(offset.left + dpWidth - viewWidth) : 0);\n\n        offset.top -=\n            Math.min(offset.top, ((offset.top + dpHeight > viewHeight && viewHeight > dpHeight) ?\n            Math.abs(dpHeight + inputHeight - extraY) : extraY));\n\n        return offset;\n    }\n\n    /**\n    * noop - do nothing\n    */\n    function noop() {\n\n    }\n\n    /**\n    * stopPropagation - makes the code only doing this a little easier to read in line\n    */\n    function stopPropagation(e) {\n        e.stopPropagation();\n    }\n\n    /**\n    * Create a function bound to a given object\n    * Thanks to underscore.js\n    */\n    function bind(func, obj) {\n        var slice = Array.prototype.slice;\n        var args = slice.call(arguments, 2);\n        return function () {\n            return func.apply(obj, args.concat(slice.call(arguments)));\n        };\n    }\n\n    /**\n    * Lightweight drag helper.  Handles containment within the element, so that\n    * when dragging, the x is within [0,element.width] and y is within [0,element.height]\n    */\n    function draggable(element, onmove, onstart, onstop) {\n        onmove = onmove || function () { };\n        onstart = onstart || function () { };\n        onstop = onstop || function () { };\n        var doc = document;\n        var dragging = false;\n        var offset = {};\n        var maxHeight = 0;\n        var maxWidth = 0;\n        var hasTouch = ('ontouchstart' in window);\n\n        var duringDragEvents = {};\n        duringDragEvents[\"selectstart\"] = prevent;\n        duringDragEvents[\"dragstart\"] = prevent;\n        duringDragEvents[\"touchmove mousemove\"] = move;\n        duringDragEvents[\"touchend mouseup\"] = stop;\n\n        function prevent(e) {\n            if (e.stopPropagation) {\n                e.stopPropagation();\n            }\n            if (e.preventDefault) {\n                e.preventDefault();\n            }\n            e.returnValue = false;\n        }\n\n        function move(e) {\n            if (dragging) {\n                // Mouseup happened outside of window\n                if (IE && doc.documentMode < 9 && !e.button) {\n                    return stop();\n                }\n\n                var t0 = e.originalEvent && e.originalEvent.touches && e.originalEvent.touches[0];\n                var pageX = t0 && t0.pageX || e.pageX;\n                var pageY = t0 && t0.pageY || e.pageY;\n\n                var dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));\n                var dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));\n\n                if (hasTouch) {\n                    // Stop scrolling in iOS\n                    prevent(e);\n                }\n\n                onmove.apply(element, [dragX, dragY, e]);\n            }\n        }\n\n        function start(e) {\n            var rightclick = (e.which) ? (e.which == 3) : (e.button == 2);\n\n            if (!rightclick && !dragging) {\n                if (onstart.apply(element, arguments) !== false) {\n                    dragging = true;\n                    maxHeight = $(element).height();\n                    maxWidth = $(element).width();\n                    offset = $(element).offset();\n\n                    $(doc).bind(duringDragEvents);\n                    $(doc.body).addClass(\"sp-dragging\");\n\n                    move(e);\n\n                    prevent(e);\n                }\n            }\n        }\n\n        function stop() {\n            if (dragging) {\n                $(doc).unbind(duringDragEvents);\n                $(doc.body).removeClass(\"sp-dragging\");\n\n                // Wait a tick before notifying observers to allow the click event\n                // to fire in Chrome.\n                setTimeout(function() {\n                    onstop.apply(element, arguments);\n                }, 0);\n            }\n            dragging = false;\n        }\n\n        $(element).bind(\"touchstart mousedown\", start);\n    }\n\n    function throttle(func, wait, debounce) {\n        var timeout;\n        return function () {\n            var context = this, args = arguments;\n            var throttler = function () {\n                timeout = null;\n                func.apply(context, args);\n            };\n            if (debounce) clearTimeout(timeout);\n            if (debounce || !timeout) timeout = setTimeout(throttler, wait);\n        };\n    }\n\n    function inputTypeColorSupport() {\n        return $.fn.spectrum.inputTypeColorSupport();\n    }\n\n    /**\n    * Define a jQuery plugin\n    */\n    var dataID = \"spectrum.id\";\n    $.fn.spectrum = function (opts, extra) {\n\n        if (typeof opts == \"string\") {\n\n            var returnValue = this;\n            var args = Array.prototype.slice.call( arguments, 1 );\n\n            this.each(function () {\n                var spect = spectrums[$(this).data(dataID)];\n                if (spect) {\n                    var method = spect[opts];\n                    if (!method) {\n                        throw new Error( \"Spectrum: no such method: '\" + opts + \"'\" );\n                    }\n\n                    if (opts == \"get\") {\n                        returnValue = spect.get();\n                    }\n                    else if (opts == \"container\") {\n                        returnValue = spect.container;\n                    }\n                    else if (opts == \"option\") {\n                        returnValue = spect.option.apply(spect, args);\n                    }\n                    else if (opts == \"destroy\") {\n                        spect.destroy();\n                        $(this).removeData(dataID);\n                    }\n                    else {\n                        method.apply(spect, args);\n                    }\n                }\n            });\n\n            return returnValue;\n        }\n\n        // Initializing a new instance of spectrum\n        return this.spectrum(\"destroy\").each(function () {\n            var options = $.extend({}, opts, $(this).data());\n            var spect = spectrum(this, options);\n            $(this).data(dataID, spect.id);\n        });\n    };\n\n    $.fn.spectrum.load = true;\n    $.fn.spectrum.loadOpts = {};\n    $.fn.spectrum.draggable = draggable;\n    $.fn.spectrum.defaults = defaultOpts;\n    $.fn.spectrum.inputTypeColorSupport = function inputTypeColorSupport() {\n        if (typeof inputTypeColorSupport._cachedResult === \"undefined\") {\n            var colorInput = $(\"<input type='color'/>\")[0]; // if color element is supported, value will default to not null\n            inputTypeColorSupport._cachedResult = colorInput.type === \"color\" && colorInput.value !== \"\";\n        }\n        return inputTypeColorSupport._cachedResult;\n    };\n\n    $.spectrum = { };\n    $.spectrum.localization = { };\n    $.spectrum.palettes = { };\n\n    $.fn.spectrum.processNativeColorInputs = function () {\n        var colorInputs = $(\"input[type=color]\");\n        if (colorInputs.length && !inputTypeColorSupport()) {\n            colorInputs.spectrum({\n                preferredFormat: \"hex6\"\n            });\n        }\n    };\n\n    // TinyColor v1.1.2\n    // https://github.com/bgrins/TinyColor\n    // Brian Grinstead, MIT License\n\n    (function() {\n\n    var trimLeft = /^[\\s,#]+/,\n        trimRight = /\\s+$/,\n        tinyCounter = 0,\n        math = Math,\n        mathRound = math.round,\n        mathMin = math.min,\n        mathMax = math.max,\n        mathRandom = math.random;\n\n    var tinycolor = function(color, opts) {\n\n        color = (color) ? color : '';\n        opts = opts || { };\n\n        // If input is already a tinycolor, return itself\n        if (color instanceof tinycolor) {\n           return color;\n        }\n        // If we are called as a function, call using new instead\n        if (!(this instanceof tinycolor)) {\n            return new tinycolor(color, opts);\n        }\n\n        var rgb = inputToRGB(color);\n        this._originalInput = color,\n        this._r = rgb.r,\n        this._g = rgb.g,\n        this._b = rgb.b,\n        this._a = rgb.a,\n        this._roundA = mathRound(100*this._a) / 100,\n        this._format = opts.format || rgb.format;\n        this._gradientType = opts.gradientType;\n\n        // Don't let the range of [0,255] come back in [0,1].\n        // Potentially lose a little bit of precision here, but will fix issues where\n        // .5 gets interpreted as half of the total, instead of half of 1\n        // If it was supposed to be 128, this was already taken care of by `inputToRgb`\n        if (this._r < 1) { this._r = mathRound(this._r); }\n        if (this._g < 1) { this._g = mathRound(this._g); }\n        if (this._b < 1) { this._b = mathRound(this._b); }\n\n        this._ok = rgb.ok;\n        this._tc_id = tinyCounter++;\n    };\n\n    tinycolor.prototype = {\n        isDark: function() {\n            return this.getBrightness() < 128;\n        },\n        isLight: function() {\n            return !this.isDark();\n        },\n        isValid: function() {\n            return this._ok;\n        },\n        getOriginalInput: function() {\n          return this._originalInput;\n        },\n        getFormat: function() {\n            return this._format;\n        },\n        getAlpha: function() {\n            return this._a;\n        },\n        getBrightness: function() {\n            var rgb = this.toRgb();\n            return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;\n        },\n        setAlpha: function(value) {\n            this._a = boundAlpha(value);\n            this._roundA = mathRound(100*this._a) / 100;\n            return this;\n        },\n        toHsv: function() {\n            var hsv = rgbToHsv(this._r, this._g, this._b);\n            return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this._a };\n        },\n        toHsvString: function() {\n            var hsv = rgbToHsv(this._r, this._g, this._b);\n            var h = mathRound(hsv.h * 360), s = mathRound(hsv.s * 100), v = mathRound(hsv.v * 100);\n            return (this._a == 1) ?\n              \"hsv(\"  + h + \", \" + s + \"%, \" + v + \"%)\" :\n              \"hsva(\" + h + \", \" + s + \"%, \" + v + \"%, \"+ this._roundA + \")\";\n        },\n        toHsl: function() {\n            var hsl = rgbToHsl(this._r, this._g, this._b);\n            return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this._a };\n        },\n        toHslString: function() {\n            var hsl = rgbToHsl(this._r, this._g, this._b);\n            var h = mathRound(hsl.h * 360), s = mathRound(hsl.s * 100), l = mathRound(hsl.l * 100);\n            return (this._a == 1) ?\n              \"hsl(\"  + h + \", \" + s + \"%, \" + l + \"%)\" :\n              \"hsla(\" + h + \", \" + s + \"%, \" + l + \"%, \"+ this._roundA + \")\";\n        },\n        toHex: function(allow3Char) {\n            return rgbToHex(this._r, this._g, this._b, allow3Char);\n        },\n        toHexString: function(allow3Char) {\n            return '#' + this.toHex(allow3Char);\n        },\n        toHex8: function() {\n            return rgbaToHex(this._r, this._g, this._b, this._a);\n        },\n        toHex8String: function() {\n            return '#' + this.toHex8();\n        },\n        toRgb: function() {\n            return { r: mathRound(this._r), g: mathRound(this._g), b: mathRound(this._b), a: this._a };\n        },\n        toRgbString: function() {\n            return (this._a == 1) ?\n              \"rgb(\"  + mathRound(this._r) + \", \" + mathRound(this._g) + \", \" + mathRound(this._b) + \")\" :\n              \"rgba(\" + mathRound(this._r) + \", \" + mathRound(this._g) + \", \" + mathRound(this._b) + \", \" + this._roundA + \")\";\n        },\n        toPercentageRgb: function() {\n            return { r: mathRound(bound01(this._r, 255) * 100) + \"%\", g: mathRound(bound01(this._g, 255) * 100) + \"%\", b: mathRound(bound01(this._b, 255) * 100) + \"%\", a: this._a };\n        },\n        toPercentageRgbString: function() {\n            return (this._a == 1) ?\n              \"rgb(\"  + mathRound(bound01(this._r, 255) * 100) + \"%, \" + mathRound(bound01(this._g, 255) * 100) + \"%, \" + mathRound(bound01(this._b, 255) * 100) + \"%)\" :\n              \"rgba(\" + mathRound(bound01(this._r, 255) * 100) + \"%, \" + mathRound(bound01(this._g, 255) * 100) + \"%, \" + mathRound(bound01(this._b, 255) * 100) + \"%, \" + this._roundA + \")\";\n        },\n        toName: function() {\n            if (this._a === 0) {\n                return \"transparent\";\n            }\n\n            if (this._a < 1) {\n                return false;\n            }\n\n            return hexNames[rgbToHex(this._r, this._g, this._b, true)] || false;\n        },\n        toFilter: function(secondColor) {\n            var hex8String = '#' + rgbaToHex(this._r, this._g, this._b, this._a);\n            var secondHex8String = hex8String;\n            var gradientType = this._gradientType ? \"GradientType = 1, \" : \"\";\n\n            if (secondColor) {\n                var s = tinycolor(secondColor);\n                secondHex8String = s.toHex8String();\n            }\n\n            return \"progid:DXImageTransform.Microsoft.gradient(\"+gradientType+\"startColorstr=\"+hex8String+\",endColorstr=\"+secondHex8String+\")\";\n        },\n        toString: function(format) {\n            var formatSet = !!format;\n            format = format || this._format;\n\n            var formattedString = false;\n            var hasAlpha = this._a < 1 && this._a >= 0;\n            var needsAlphaFormat = !formatSet && hasAlpha && (format === \"hex\" || format === \"hex6\" || format === \"hex3\" || format === \"name\");\n\n            if (needsAlphaFormat) {\n                // Special case for \"transparent\", all other non-alpha formats\n                // will return rgba when there is transparency.\n                if (format === \"name\" && this._a === 0) {\n                    return this.toName();\n                }\n                return this.toRgbString();\n            }\n            if (format === \"rgb\") {\n                formattedString = this.toRgbString();\n            }\n            if (format === \"prgb\") {\n                formattedString = this.toPercentageRgbString();\n            }\n            if (format === \"hex\" || format === \"hex6\") {\n                formattedString = this.toHexString();\n            }\n            if (format === \"hex3\") {\n                formattedString = this.toHexString(true);\n            }\n            if (format === \"hex8\") {\n                formattedString = this.toHex8String();\n            }\n            if (format === \"name\") {\n                formattedString = this.toName();\n            }\n            if (format === \"hsl\") {\n                formattedString = this.toHslString();\n            }\n            if (format === \"hsv\") {\n                formattedString = this.toHsvString();\n            }\n\n            return formattedString || this.toHexString();\n        },\n\n        _applyModification: function(fn, args) {\n            var color = fn.apply(null, [this].concat([].slice.call(args)));\n            this._r = color._r;\n            this._g = color._g;\n            this._b = color._b;\n            this.setAlpha(color._a);\n            return this;\n        },\n        lighten: function() {\n            return this._applyModification(lighten, arguments);\n        },\n        brighten: function() {\n            return this._applyModification(brighten, arguments);\n        },\n        darken: function() {\n            return this._applyModification(darken, arguments);\n        },\n        desaturate: function() {\n            return this._applyModification(desaturate, arguments);\n        },\n        saturate: function() {\n            return this._applyModification(saturate, arguments);\n        },\n        greyscale: function() {\n            return this._applyModification(greyscale, arguments);\n        },\n        spin: function() {\n            return this._applyModification(spin, arguments);\n        },\n\n        _applyCombination: function(fn, args) {\n            return fn.apply(null, [this].concat([].slice.call(args)));\n        },\n        analogous: function() {\n            return this._applyCombination(analogous, arguments);\n        },\n        complement: function() {\n            return this._applyCombination(complement, arguments);\n        },\n        monochromatic: function() {\n            return this._applyCombination(monochromatic, arguments);\n        },\n        splitcomplement: function() {\n            return this._applyCombination(splitcomplement, arguments);\n        },\n        triad: function() {\n            return this._applyCombination(triad, arguments);\n        },\n        tetrad: function() {\n            return this._applyCombination(tetrad, arguments);\n        }\n    };\n\n    // If input is an object, force 1 into \"1.0\" to handle ratios properly\n    // String input requires \"1.0\" as input, so 1 will be treated as 1\n    tinycolor.fromRatio = function(color, opts) {\n        if (typeof color == \"object\") {\n            var newColor = {};\n            for (var i in color) {\n                if (color.hasOwnProperty(i)) {\n                    if (i === \"a\") {\n                        newColor[i] = color[i];\n                    }\n                    else {\n                        newColor[i] = convertToPercentage(color[i]);\n                    }\n                }\n            }\n            color = newColor;\n        }\n\n        return tinycolor(color, opts);\n    };\n\n    // Given a string or object, convert that input to RGB\n    // Possible string inputs:\n    //\n    //     \"red\"\n    //     \"#f00\" or \"f00\"\n    //     \"#ff0000\" or \"ff0000\"\n    //     \"#ff000000\" or \"ff000000\"\n    //     \"rgb 255 0 0\" or \"rgb (255, 0, 0)\"\n    //     \"rgb 1.0 0 0\" or \"rgb (1, 0, 0)\"\n    //     \"rgba (255, 0, 0, 1)\" or \"rgba 255, 0, 0, 1\"\n    //     \"rgba (1.0, 0, 0, 1)\" or \"rgba 1.0, 0, 0, 1\"\n    //     \"hsl(0, 100%, 50%)\" or \"hsl 0 100% 50%\"\n    //     \"hsla(0, 100%, 50%, 1)\" or \"hsla 0 100% 50%, 1\"\n    //     \"hsv(0, 100%, 100%)\" or \"hsv 0 100% 100%\"\n    //\n    function inputToRGB(color) {\n\n        var rgb = { r: 0, g: 0, b: 0 };\n        var a = 1;\n        var ok = false;\n        var format = false;\n\n        if (typeof color == \"string\") {\n            color = stringInputToObject(color);\n        }\n\n        if (typeof color == \"object\") {\n            if (color.hasOwnProperty(\"r\") && color.hasOwnProperty(\"g\") && color.hasOwnProperty(\"b\")) {\n                rgb = rgbToRgb(color.r, color.g, color.b);\n                ok = true;\n                format = String(color.r).substr(-1) === \"%\" ? \"prgb\" : \"rgb\";\n            }\n            else if (color.hasOwnProperty(\"h\") && color.hasOwnProperty(\"s\") && color.hasOwnProperty(\"v\")) {\n                color.s = convertToPercentage(color.s);\n                color.v = convertToPercentage(color.v);\n                rgb = hsvToRgb(color.h, color.s, color.v);\n                ok = true;\n                format = \"hsv\";\n            }\n            else if (color.hasOwnProperty(\"h\") && color.hasOwnProperty(\"s\") && color.hasOwnProperty(\"l\")) {\n                color.s = convertToPercentage(color.s);\n                color.l = convertToPercentage(color.l);\n                rgb = hslToRgb(color.h, color.s, color.l);\n                ok = true;\n                format = \"hsl\";\n            }\n\n            if (color.hasOwnProperty(\"a\")) {\n                a = color.a;\n            }\n        }\n\n        a = boundAlpha(a);\n\n        return {\n            ok: ok,\n            format: color.format || format,\n            r: mathMin(255, mathMax(rgb.r, 0)),\n            g: mathMin(255, mathMax(rgb.g, 0)),\n            b: mathMin(255, mathMax(rgb.b, 0)),\n            a: a\n        };\n    }\n\n\n    // Conversion Functions\n    // --------------------\n\n    // `rgbToHsl`, `rgbToHsv`, `hslToRgb`, `hsvToRgb` modified from:\n    // <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript>\n\n    // `rgbToRgb`\n    // Handle bounds / percentage checking to conform to CSS color spec\n    // <http://www.w3.org/TR/css3-color/>\n    // *Assumes:* r, g, b in [0, 255] or [0, 1]\n    // *Returns:* { r, g, b } in [0, 255]\n    function rgbToRgb(r, g, b){\n        return {\n            r: bound01(r, 255) * 255,\n            g: bound01(g, 255) * 255,\n            b: bound01(b, 255) * 255\n        };\n    }\n\n    // `rgbToHsl`\n    // Converts an RGB color value to HSL.\n    // *Assumes:* r, g, and b are contained in [0, 255] or [0, 1]\n    // *Returns:* { h, s, l } in [0,1]\n    function rgbToHsl(r, g, b) {\n\n        r = bound01(r, 255);\n        g = bound01(g, 255);\n        b = bound01(b, 255);\n\n        var max = mathMax(r, g, b), min = mathMin(r, g, b);\n        var h, s, l = (max + min) / 2;\n\n        if(max == min) {\n            h = s = 0; // achromatic\n        }\n        else {\n            var d = max - min;\n            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n            switch(max) {\n                case r: h = (g - b) / d + (g < b ? 6 : 0); break;\n                case g: h = (b - r) / d + 2; break;\n                case b: h = (r - g) / d + 4; break;\n            }\n\n            h /= 6;\n        }\n\n        return { h: h, s: s, l: l };\n    }\n\n    // `hslToRgb`\n    // Converts an HSL color value to RGB.\n    // *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100]\n    // *Returns:* { r, g, b } in the set [0, 255]\n    function hslToRgb(h, s, l) {\n        var r, g, b;\n\n        h = bound01(h, 360);\n        s = bound01(s, 100);\n        l = bound01(l, 100);\n\n        function hue2rgb(p, q, t) {\n            if(t < 0) t += 1;\n            if(t > 1) t -= 1;\n            if(t < 1/6) return p + (q - p) * 6 * t;\n            if(t < 1/2) return q;\n            if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;\n            return p;\n        }\n\n        if(s === 0) {\n            r = g = b = l; // achromatic\n        }\n        else {\n            var q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n            var p = 2 * l - q;\n            r = hue2rgb(p, q, h + 1/3);\n            g = hue2rgb(p, q, h);\n            b = hue2rgb(p, q, h - 1/3);\n        }\n\n        return { r: r * 255, g: g * 255, b: b * 255 };\n    }\n\n    // `rgbToHsv`\n    // Converts an RGB color value to HSV\n    // *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1]\n    // *Returns:* { h, s, v } in [0,1]\n    function rgbToHsv(r, g, b) {\n\n        r = bound01(r, 255);\n        g = bound01(g, 255);\n        b = bound01(b, 255);\n\n        var max = mathMax(r, g, b), min = mathMin(r, g, b);\n        var h, s, v = max;\n\n        var d = max - min;\n        s = max === 0 ? 0 : d / max;\n\n        if(max == min) {\n            h = 0; // achromatic\n        }\n        else {\n            switch(max) {\n                case r: h = (g - b) / d + (g < b ? 6 : 0); break;\n                case g: h = (b - r) / d + 2; break;\n                case b: h = (r - g) / d + 4; break;\n            }\n            h /= 6;\n        }\n        return { h: h, s: s, v: v };\n    }\n\n    // `hsvToRgb`\n    // Converts an HSV color value to RGB.\n    // *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100]\n    // *Returns:* { r, g, b } in the set [0, 255]\n     function hsvToRgb(h, s, v) {\n\n        h = bound01(h, 360) * 6;\n        s = bound01(s, 100);\n        v = bound01(v, 100);\n\n        var i = math.floor(h),\n            f = h - i,\n            p = v * (1 - s),\n            q = v * (1 - f * s),\n            t = v * (1 - (1 - f) * s),\n            mod = i % 6,\n            r = [v, q, p, p, t, v][mod],\n            g = [t, v, v, q, p, p][mod],\n            b = [p, p, t, v, v, q][mod];\n\n        return { r: r * 255, g: g * 255, b: b * 255 };\n    }\n\n    // `rgbToHex`\n    // Converts an RGB color to hex\n    // Assumes r, g, and b are contained in the set [0, 255]\n    // Returns a 3 or 6 character hex\n    function rgbToHex(r, g, b, allow3Char) {\n\n        var hex = [\n            pad2(mathRound(r).toString(16)),\n            pad2(mathRound(g).toString(16)),\n            pad2(mathRound(b).toString(16))\n        ];\n\n        // Return a 3 character hex if possible\n        if (allow3Char && hex[0].charAt(0) == hex[0].charAt(1) && hex[1].charAt(0) == hex[1].charAt(1) && hex[2].charAt(0) == hex[2].charAt(1)) {\n            return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0);\n        }\n\n        return hex.join(\"\");\n    }\n        // `rgbaToHex`\n        // Converts an RGBA color plus alpha transparency to hex\n        // Assumes r, g, b and a are contained in the set [0, 255]\n        // Returns an 8 character hex\n        function rgbaToHex(r, g, b, a) {\n\n            var hex = [\n                pad2(convertDecimalToHex(a)),\n                pad2(mathRound(r).toString(16)),\n                pad2(mathRound(g).toString(16)),\n                pad2(mathRound(b).toString(16))\n            ];\n\n            return hex.join(\"\");\n        }\n\n    // `equals`\n    // Can be called with any tinycolor input\n    tinycolor.equals = function (color1, color2) {\n        if (!color1 || !color2) { return false; }\n        return tinycolor(color1).toRgbString() == tinycolor(color2).toRgbString();\n    };\n    tinycolor.random = function() {\n        return tinycolor.fromRatio({\n            r: mathRandom(),\n            g: mathRandom(),\n            b: mathRandom()\n        });\n    };\n\n\n    // Modification Functions\n    // ----------------------\n    // Thanks to less.js for some of the basics here\n    // <https://github.com/cloudhead/less.js/blob/master/lib/less/functions.js>\n\n    function desaturate(color, amount) {\n        amount = (amount === 0) ? 0 : (amount || 10);\n        var hsl = tinycolor(color).toHsl();\n        hsl.s -= amount / 100;\n        hsl.s = clamp01(hsl.s);\n        return tinycolor(hsl);\n    }\n\n    function saturate(color, amount) {\n        amount = (amount === 0) ? 0 : (amount || 10);\n        var hsl = tinycolor(color).toHsl();\n        hsl.s += amount / 100;\n        hsl.s = clamp01(hsl.s);\n        return tinycolor(hsl);\n    }\n\n    function greyscale(color) {\n        return tinycolor(color).desaturate(100);\n    }\n\n    function lighten (color, amount) {\n        amount = (amount === 0) ? 0 : (amount || 10);\n        var hsl = tinycolor(color).toHsl();\n        hsl.l += amount / 100;\n        hsl.l = clamp01(hsl.l);\n        return tinycolor(hsl);\n    }\n\n    function brighten(color, amount) {\n        amount = (amount === 0) ? 0 : (amount || 10);\n        var rgb = tinycolor(color).toRgb();\n        rgb.r = mathMax(0, mathMin(255, rgb.r - mathRound(255 * - (amount / 100))));\n        rgb.g = mathMax(0, mathMin(255, rgb.g - mathRound(255 * - (amount / 100))));\n        rgb.b = mathMax(0, mathMin(255, rgb.b - mathRound(255 * - (amount / 100))));\n        return tinycolor(rgb);\n    }\n\n    function darken (color, amount) {\n        amount = (amount === 0) ? 0 : (amount || 10);\n        var hsl = tinycolor(color).toHsl();\n        hsl.l -= amount / 100;\n        hsl.l = clamp01(hsl.l);\n        return tinycolor(hsl);\n    }\n\n    // Spin takes a positive or negative amount within [-360, 360] indicating the change of hue.\n    // Values outside of this range will be wrapped into this range.\n    function spin(color, amount) {\n        var hsl = tinycolor(color).toHsl();\n        var hue = (mathRound(hsl.h) + amount) % 360;\n        hsl.h = hue < 0 ? 360 + hue : hue;\n        return tinycolor(hsl);\n    }\n\n    // Combination Functions\n    // ---------------------\n    // Thanks to jQuery xColor for some of the ideas behind these\n    // <https://github.com/infusion/jQuery-xcolor/blob/master/jquery.xcolor.js>\n\n    function complement(color) {\n        var hsl = tinycolor(color).toHsl();\n        hsl.h = (hsl.h + 180) % 360;\n        return tinycolor(hsl);\n    }\n\n    function triad(color) {\n        var hsl = tinycolor(color).toHsl();\n        var h = hsl.h;\n        return [\n            tinycolor(color),\n            tinycolor({ h: (h + 120) % 360, s: hsl.s, l: hsl.l }),\n            tinycolor({ h: (h + 240) % 360, s: hsl.s, l: hsl.l })\n        ];\n    }\n\n    function tetrad(color) {\n        var hsl = tinycolor(color).toHsl();\n        var h = hsl.h;\n        return [\n            tinycolor(color),\n            tinycolor({ h: (h + 90) % 360, s: hsl.s, l: hsl.l }),\n            tinycolor({ h: (h + 180) % 360, s: hsl.s, l: hsl.l }),\n            tinycolor({ h: (h + 270) % 360, s: hsl.s, l: hsl.l })\n        ];\n    }\n\n    function splitcomplement(color) {\n        var hsl = tinycolor(color).toHsl();\n        var h = hsl.h;\n        return [\n            tinycolor(color),\n            tinycolor({ h: (h + 72) % 360, s: hsl.s, l: hsl.l}),\n            tinycolor({ h: (h + 216) % 360, s: hsl.s, l: hsl.l})\n        ];\n    }\n\n    function analogous(color, results, slices) {\n        results = results || 6;\n        slices = slices || 30;\n\n        var hsl = tinycolor(color).toHsl();\n        var part = 360 / slices;\n        var ret = [tinycolor(color)];\n\n        for (hsl.h = ((hsl.h - (part * results >> 1)) + 720) % 360; --results; ) {\n            hsl.h = (hsl.h + part) % 360;\n            ret.push(tinycolor(hsl));\n        }\n        return ret;\n    }\n\n    function monochromatic(color, results) {\n        results = results || 6;\n        var hsv = tinycolor(color).toHsv();\n        var h = hsv.h, s = hsv.s, v = hsv.v;\n        var ret = [];\n        var modification = 1 / results;\n\n        while (results--) {\n            ret.push(tinycolor({ h: h, s: s, v: v}));\n            v = (v + modification) % 1;\n        }\n\n        return ret;\n    }\n\n    // Utility Functions\n    // ---------------------\n\n    tinycolor.mix = function(color1, color2, amount) {\n        amount = (amount === 0) ? 0 : (amount || 50);\n\n        var rgb1 = tinycolor(color1).toRgb();\n        var rgb2 = tinycolor(color2).toRgb();\n\n        var p = amount / 100;\n        var w = p * 2 - 1;\n        var a = rgb2.a - rgb1.a;\n\n        var w1;\n\n        if (w * a == -1) {\n            w1 = w;\n        } else {\n            w1 = (w + a) / (1 + w * a);\n        }\n\n        w1 = (w1 + 1) / 2;\n\n        var w2 = 1 - w1;\n\n        var rgba = {\n            r: rgb2.r * w1 + rgb1.r * w2,\n            g: rgb2.g * w1 + rgb1.g * w2,\n            b: rgb2.b * w1 + rgb1.b * w2,\n            a: rgb2.a * p  + rgb1.a * (1 - p)\n        };\n\n        return tinycolor(rgba);\n    };\n\n\n    // Readability Functions\n    // ---------------------\n    // <http://www.w3.org/TR/AERT#color-contrast>\n\n    // `readability`\n    // Analyze the 2 colors and returns an object with the following properties:\n    //    `brightness`: difference in brightness between the two colors\n    //    `color`: difference in color/hue between the two colors\n    tinycolor.readability = function(color1, color2) {\n        var c1 = tinycolor(color1);\n        var c2 = tinycolor(color2);\n        var rgb1 = c1.toRgb();\n        var rgb2 = c2.toRgb();\n        var brightnessA = c1.getBrightness();\n        var brightnessB = c2.getBrightness();\n        var colorDiff = (\n            Math.max(rgb1.r, rgb2.r) - Math.min(rgb1.r, rgb2.r) +\n            Math.max(rgb1.g, rgb2.g) - Math.min(rgb1.g, rgb2.g) +\n            Math.max(rgb1.b, rgb2.b) - Math.min(rgb1.b, rgb2.b)\n        );\n\n        return {\n            brightness: Math.abs(brightnessA - brightnessB),\n            color: colorDiff\n        };\n    };\n\n    // `readable`\n    // http://www.w3.org/TR/AERT#color-contrast\n    // Ensure that foreground and background color combinations provide sufficient contrast.\n    // *Example*\n    //    tinycolor.isReadable(\"#000\", \"#111\") => false\n    tinycolor.isReadable = function(color1, color2) {\n        var readability = tinycolor.readability(color1, color2);\n        return readability.brightness > 125 && readability.color > 500;\n    };\n\n    // `mostReadable`\n    // Given a base color and a list of possible foreground or background\n    // colors for that base, returns the most readable color.\n    // *Example*\n    //    tinycolor.mostReadable(\"#123\", [\"#fff\", \"#000\"]) => \"#000\"\n    tinycolor.mostReadable = function(baseColor, colorList) {\n        var bestColor = null;\n        var bestScore = 0;\n        var bestIsReadable = false;\n        for (var i=0; i < colorList.length; i++) {\n\n            // We normalize both around the \"acceptable\" breaking point,\n            // but rank brightness constrast higher than hue.\n\n            var readability = tinycolor.readability(baseColor, colorList[i]);\n            var readable = readability.brightness > 125 && readability.color > 500;\n            var score = 3 * (readability.brightness / 125) + (readability.color / 500);\n\n            if ((readable && ! bestIsReadable) ||\n                (readable && bestIsReadable && score > bestScore) ||\n                ((! readable) && (! bestIsReadable) && score > bestScore)) {\n                bestIsReadable = readable;\n                bestScore = score;\n                bestColor = tinycolor(colorList[i]);\n            }\n        }\n        return bestColor;\n    };\n\n\n    // Big List of Colors\n    // ------------------\n    // <http://www.w3.org/TR/css3-color/#svg-color>\n    var names = tinycolor.names = {\n        aliceblue: \"f0f8ff\",\n        antiquewhite: \"faebd7\",\n        aqua: \"0ff\",\n        aquamarine: \"7fffd4\",\n        azure: \"f0ffff\",\n        beige: \"f5f5dc\",\n        bisque: \"ffe4c4\",\n        black: \"000\",\n        blanchedalmond: \"ffebcd\",\n        blue: \"00f\",\n        blueviolet: \"8a2be2\",\n        brown: \"a52a2a\",\n        burlywood: \"deb887\",\n        burntsienna: \"ea7e5d\",\n        cadetblue: \"5f9ea0\",\n        chartreuse: \"7fff00\",\n        chocolate: \"d2691e\",\n        coral: \"ff7f50\",\n        cornflowerblue: \"6495ed\",\n        cornsilk: \"fff8dc\",\n        crimson: \"dc143c\",\n        cyan: \"0ff\",\n        darkblue: \"00008b\",\n        darkcyan: \"008b8b\",\n        darkgoldenrod: \"b8860b\",\n        darkgray: \"a9a9a9\",\n        darkgreen: \"006400\",\n        darkgrey: \"a9a9a9\",\n        darkkhaki: \"bdb76b\",\n        darkmagenta: \"8b008b\",\n        darkolivegreen: \"556b2f\",\n        darkorange: \"ff8c00\",\n        darkorchid: \"9932cc\",\n        darkred: \"8b0000\",\n        darksalmon: \"e9967a\",\n        darkseagreen: \"8fbc8f\",\n        darkslateblue: \"483d8b\",\n        darkslategray: \"2f4f4f\",\n        darkslategrey: \"2f4f4f\",\n        darkturquoise: \"00ced1\",\n        darkviolet: \"9400d3\",\n        deeppink: \"ff1493\",\n        deepskyblue: \"00bfff\",\n        dimgray: \"696969\",\n        dimgrey: \"696969\",\n        dodgerblue: \"1e90ff\",\n        firebrick: \"b22222\",\n        floralwhite: \"fffaf0\",\n        forestgreen: \"228b22\",\n        fuchsia: \"f0f\",\n        gainsboro: \"dcdcdc\",\n        ghostwhite: \"f8f8ff\",\n        gold: \"ffd700\",\n        goldenrod: \"daa520\",\n        gray: \"808080\",\n        green: \"008000\",\n        greenyellow: \"adff2f\",\n        grey: \"808080\",\n        honeydew: \"f0fff0\",\n        hotpink: \"ff69b4\",\n        indianred: \"cd5c5c\",\n        indigo: \"4b0082\",\n        ivory: \"fffff0\",\n        khaki: \"f0e68c\",\n        lavender: \"e6e6fa\",\n        lavenderblush: \"fff0f5\",\n        lawngreen: \"7cfc00\",\n        lemonchiffon: \"fffacd\",\n        lightblue: \"add8e6\",\n        lightcoral: \"f08080\",\n        lightcyan: \"e0ffff\",\n        lightgoldenrodyellow: \"fafad2\",\n        lightgray: \"d3d3d3\",\n        lightgreen: \"90ee90\",\n        lightgrey: \"d3d3d3\",\n        lightpink: \"ffb6c1\",\n        lightsalmon: \"ffa07a\",\n        lightseagreen: \"20b2aa\",\n        lightskyblue: \"87cefa\",\n        lightslategray: \"789\",\n        lightslategrey: \"789\",\n        lightsteelblue: \"b0c4de\",\n        lightyellow: \"ffffe0\",\n        lime: \"0f0\",\n        limegreen: \"32cd32\",\n        linen: \"faf0e6\",\n        magenta: \"f0f\",\n        maroon: \"800000\",\n        mediumaquamarine: \"66cdaa\",\n        mediumblue: \"0000cd\",\n        mediumorchid: \"ba55d3\",\n        mediumpurple: \"9370db\",\n        mediumseagreen: \"3cb371\",\n        mediumslateblue: \"7b68ee\",\n        mediumspringgreen: \"00fa9a\",\n        mediumturquoise: \"48d1cc\",\n        mediumvioletred: \"c71585\",\n        midnightblue: \"191970\",\n        mintcream: \"f5fffa\",\n        mistyrose: \"ffe4e1\",\n        moccasin: \"ffe4b5\",\n        navajowhite: \"ffdead\",\n        navy: \"000080\",\n        oldlace: \"fdf5e6\",\n        olive: \"808000\",\n        olivedrab: \"6b8e23\",\n        orange: \"ffa500\",\n        orangered: \"ff4500\",\n        orchid: \"da70d6\",\n        palegoldenrod: \"eee8aa\",\n        palegreen: \"98fb98\",\n        paleturquoise: \"afeeee\",\n        palevioletred: \"db7093\",\n        papayawhip: \"ffefd5\",\n        peachpuff: \"ffdab9\",\n        peru: \"cd853f\",\n        pink: \"ffc0cb\",\n        plum: \"dda0dd\",\n        powderblue: \"b0e0e6\",\n        purple: \"800080\",\n        rebeccapurple: \"663399\",\n        red: \"f00\",\n        rosybrown: \"bc8f8f\",\n        royalblue: \"4169e1\",\n        saddlebrown: \"8b4513\",\n        salmon: \"fa8072\",\n        sandybrown: \"f4a460\",\n        seagreen: \"2e8b57\",\n        seashell: \"fff5ee\",\n        sienna: \"a0522d\",\n        silver: \"c0c0c0\",\n        skyblue: \"87ceeb\",\n        slateblue: \"6a5acd\",\n        slategray: \"708090\",\n        slategrey: \"708090\",\n        snow: \"fffafa\",\n        springgreen: \"00ff7f\",\n        steelblue: \"4682b4\",\n        tan: \"d2b48c\",\n        teal: \"008080\",\n        thistle: \"d8bfd8\",\n        tomato: \"ff6347\",\n        turquoise: \"40e0d0\",\n        violet: \"ee82ee\",\n        wheat: \"f5deb3\",\n        white: \"fff\",\n        whitesmoke: \"f5f5f5\",\n        yellow: \"ff0\",\n        yellowgreen: \"9acd32\"\n    };\n\n    // Make it easy to access colors via `hexNames[hex]`\n    var hexNames = tinycolor.hexNames = flip(names);\n\n\n    // Utilities\n    // ---------\n\n    // `{ 'name1': 'val1' }` becomes `{ 'val1': 'name1' }`\n    function flip(o) {\n        var flipped = { };\n        for (var i in o) {\n            if (o.hasOwnProperty(i)) {\n                flipped[o[i]] = i;\n            }\n        }\n        return flipped;\n    }\n\n    // Return a valid alpha value [0,1] with all invalid values being set to 1\n    function boundAlpha(a) {\n        a = parseFloat(a);\n\n        if (isNaN(a) || a < 0 || a > 1) {\n            a = 1;\n        }\n\n        return a;\n    }\n\n    // Take input from [0, n] and return it as [0, 1]\n    function bound01(n, max) {\n        if (isOnePointZero(n)) { n = \"100%\"; }\n\n        var processPercent = isPercentage(n);\n        n = mathMin(max, mathMax(0, parseFloat(n)));\n\n        // Automatically convert percentage into number\n        if (processPercent) {\n            n = parseInt(n * max, 10) / 100;\n        }\n\n        // Handle floating point rounding errors\n        if ((math.abs(n - max) < 0.000001)) {\n            return 1;\n        }\n\n        // Convert into [0, 1] range if it isn't already\n        return (n % max) / parseFloat(max);\n    }\n\n    // Force a number between 0 and 1\n    function clamp01(val) {\n        return mathMin(1, mathMax(0, val));\n    }\n\n    // Parse a base-16 hex value into a base-10 integer\n    function parseIntFromHex(val) {\n        return parseInt(val, 16);\n    }\n\n    // Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1\n    // <http://stackoverflow.com/questions/7422072/javascript-how-to-detect-number-as-a-decimal-including-1-0>\n    function isOnePointZero(n) {\n        return typeof n == \"string\" && n.indexOf('.') != -1 && parseFloat(n) === 1;\n    }\n\n    // Check to see if string passed in is a percentage\n    function isPercentage(n) {\n        return typeof n === \"string\" && n.indexOf('%') != -1;\n    }\n\n    // Force a hex value to have 2 characters\n    function pad2(c) {\n        return c.length == 1 ? '0' + c : '' + c;\n    }\n\n    // Replace a decimal with it's percentage value\n    function convertToPercentage(n) {\n        if (n <= 1) {\n            n = (n * 100) + \"%\";\n        }\n\n        return n;\n    }\n\n    // Converts a decimal to a hex value\n    function convertDecimalToHex(d) {\n        return Math.round(parseFloat(d) * 255).toString(16);\n    }\n    // Converts a hex value to a decimal\n    function convertHexToDecimal(h) {\n        return (parseIntFromHex(h) / 255);\n    }\n\n    var matchers = (function() {\n\n        // <http://www.w3.org/TR/css3-values/#integers>\n        var CSS_INTEGER = \"[-\\\\+]?\\\\d+%?\";\n\n        // <http://www.w3.org/TR/css3-values/#number-value>\n        var CSS_NUMBER = \"[-\\\\+]?\\\\d*\\\\.\\\\d+%?\";\n\n        // Allow positive/negative integer/number.  Don't capture the either/or, just the entire outcome.\n        var CSS_UNIT = \"(?:\" + CSS_NUMBER + \")|(?:\" + CSS_INTEGER + \")\";\n\n        // Actual matching.\n        // Parentheses and commas are optional, but not required.\n        // Whitespace can take the place of commas or opening paren\n        var PERMISSIVE_MATCH3 = \"[\\\\s|\\\\(]+(\" + CSS_UNIT + \")[,|\\\\s]+(\" + CSS_UNIT + \")[,|\\\\s]+(\" + CSS_UNIT + \")\\\\s*\\\\)?\";\n        var PERMISSIVE_MATCH4 = \"[\\\\s|\\\\(]+(\" + CSS_UNIT + \")[,|\\\\s]+(\" + CSS_UNIT + \")[,|\\\\s]+(\" + CSS_UNIT + \")[,|\\\\s]+(\" + CSS_UNIT + \")\\\\s*\\\\)?\";\n\n        return {\n            rgb: new RegExp(\"rgb\" + PERMISSIVE_MATCH3),\n            rgba: new RegExp(\"rgba\" + PERMISSIVE_MATCH4),\n            hsl: new RegExp(\"hsl\" + PERMISSIVE_MATCH3),\n            hsla: new RegExp(\"hsla\" + PERMISSIVE_MATCH4),\n            hsv: new RegExp(\"hsv\" + PERMISSIVE_MATCH3),\n            hsva: new RegExp(\"hsva\" + PERMISSIVE_MATCH4),\n            hex3: /^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,\n            hex6: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,\n            hex8: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/\n        };\n    })();\n\n    // `stringInputToObject`\n    // Permissive string parsing.  Take in a number of formats, and output an object\n    // based on detected format.  Returns `{ r, g, b }` or `{ h, s, l }` or `{ h, s, v}`\n    function stringInputToObject(color) {\n\n        color = color.replace(trimLeft,'').replace(trimRight, '').toLowerCase();\n        var named = false;\n        if (names[color]) {\n            color = names[color];\n            named = true;\n        }\n        else if (color == 'transparent') {\n            return { r: 0, g: 0, b: 0, a: 0, format: \"name\" };\n        }\n\n        // Try to match string input using regular expressions.\n        // Keep most of the number bounding out of this function - don't worry about [0,1] or [0,100] or [0,360]\n        // Just return an object and let the conversion functions handle that.\n        // This way the result will be the same whether the tinycolor is initialized with string or object.\n        var match;\n        if ((match = matchers.rgb.exec(color))) {\n            return { r: match[1], g: match[2], b: match[3] };\n        }\n        if ((match = matchers.rgba.exec(color))) {\n            return { r: match[1], g: match[2], b: match[3], a: match[4] };\n        }\n        if ((match = matchers.hsl.exec(color))) {\n            return { h: match[1], s: match[2], l: match[3] };\n        }\n        if ((match = matchers.hsla.exec(color))) {\n            return { h: match[1], s: match[2], l: match[3], a: match[4] };\n        }\n        if ((match = matchers.hsv.exec(color))) {\n            return { h: match[1], s: match[2], v: match[3] };\n        }\n        if ((match = matchers.hsva.exec(color))) {\n            return { h: match[1], s: match[2], v: match[3], a: match[4] };\n        }\n        if ((match = matchers.hex8.exec(color))) {\n            return {\n                a: convertHexToDecimal(match[1]),\n                r: parseIntFromHex(match[2]),\n                g: parseIntFromHex(match[3]),\n                b: parseIntFromHex(match[4]),\n                format: named ? \"name\" : \"hex8\"\n            };\n        }\n        if ((match = matchers.hex6.exec(color))) {\n            return {\n                r: parseIntFromHex(match[1]),\n                g: parseIntFromHex(match[2]),\n                b: parseIntFromHex(match[3]),\n                format: named ? \"name\" : \"hex\"\n            };\n        }\n        if ((match = matchers.hex3.exec(color))) {\n            return {\n                r: parseIntFromHex(match[1] + '' + match[1]),\n                g: parseIntFromHex(match[2] + '' + match[2]),\n                b: parseIntFromHex(match[3] + '' + match[3]),\n                format: named ? \"name\" : \"hex\"\n            };\n        }\n\n        return false;\n    }\n\n    window.tinycolor = tinycolor;\n    })();\n\n    $(function () {\n        if ($.fn.spectrum.load) {\n            $.fn.spectrum.processNativeColorInputs();\n        }\n    });\n\n});\n"
  },
  {
    "path": "js/admin/template.js",
    "content": "(function($) {\n\n\t$(document).ready(function() {\n\n\t\tvar $editor     = $(\"#template-editor\");\n\t\tvar $title      = $(\".editor .title input\", $editor);\n\t\tvar $toolbar    = $(\".toolbar\", $editor);\n\t\tvar $footer    =  $(\"footer\", $editor);\n\t\tvar $navigation = $(\".navigation\", $editor);\n\t\tvar $preview    = $(\"#podlove_template_shortcode_preview\");\n\n\t\tvar editor = ace.edit(\"ace-editor\");\n        var isNetwork = !!podlove_admin_network_global.is_network_admin ? \"yes\" : \"no\";\n\n\t\t$(\"#fullscreen\").on( 'click', function () {\n\t\t\t$(document.body).toggleClass(\"fullScreen\");\n\t\t\t$(\"#ace-editor\").toggleClass(\"fullScreen-editor\");\n\t\t\t$(this).toggleClass(\"fullscreen-on\").toggleClass(\"fullscreen-off\");\n\t\t\teditor.resize();\n\t\t\twindow.scroll(0,0); // reset window scrolling to avoid fullscreen-button positioning issues\n\t\t} );\n\n\t\t// local cache\n\t\tvar templates   = [];\n\n\t\tvar template = function (id, title, content) {\n\n\t\t\tvar $navigationItem = $(\"li a[data-id=\" + id + \"]\", $navigation);\n\t\t\tvar isMarked = false;\n\n\t\t\tvar markAsUnsaved = function () {\n\t\t\t\tif (!isMarked) {\n\t\t\t\t\tisMarked = true;\n\t\t\t\t\t$navigationItem.html($navigationItem.html() + '<span class=\"unsaved\" title=\"unsaved changes\"> ● </span>');\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tvar markAsSaved = function () {\n\t\t\t\tif (isMarked) {\n\t\t\t\t\tisMarked = false;\n\t\t\t\t\t$navigationItem.find(\".unsaved\").remove();\n\t\t\t\t\t$preview.val('[podlove-template template=\"' + this.title + '\"]');\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tvar activate = function () {\n\t\t\t\t$title.val(this.title);\n\t\t\t\t$preview.val('[podlove-template template=\"' + this.title + '\"]');\n\t\t\t\teditor.getSession().setValue(this.content ? this.content : \"\");\n\t\t\t};\n\n\t\t\treturn {\n\t\t\t\tid: id,\n\t\t\t\ttitle: title,\n\t\t\t\tcontent: content,\n\t\t\t\tmarkAsUnsaved: markAsUnsaved,\n\t\t\t\tmarkAsSaved: markAsSaved,\n\t\t\t\tactivate: activate\n\t\t\t}\n\t\t};\n\n\t\teditor.setTheme(\"ace/theme/chrome\");\n\t\teditor.getSession().setMode(\"ace/mode/twig\");\n\t\teditor.getSession().setUseWrapMode(true);\n\n\t\tvar activate_template = function(e) {\n\t\t\tvar $this = $(this);\n\t\t\tvar template_id = $this.data('id');\n\n\t\t\t$this.closest(\"li\")\n\t\t\t\t.addClass(\"active\")\n\t\t\t\t.siblings().removeClass(\"active\")\n\t\t\t;\n\n\t\t\tif (templates[template_id]) {\n\t\t\t\ttemplates[template_id].activate();\n\t\t\t} else {\n\t\t\t\t$.getJSON(ajaxurl, {\n\t\t\t\t\tid: template_id,\n                    is_network: isNetwork,\n\t\t\t\t\taction: 'podlove-template-get'\n\t\t\t\t}, function(data) {\n\t\t\t\t\ttemplates[template_id] = template(template_id, data.title, data.content);\n\t\t\t\t\ttemplates[template_id].activate();\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t$this.blur(); // removes link outline\n\n\t\t\tif (e) {\n\t\t\t\te.preventDefault();\n\t\t\t}\n\t\t};\n\n\t\tvar save_template = function(e) {\n\t\t\tvar save_button = $(this);\n\t\t\tvar template_id = $(\"li.active a\", $navigation).data(\"id\");\n\t\t\tvar template_title = $title.val();\n\t\t\tvar template_content = editor.getSession().getValue();\n\t\t\tvar saving_icon = '<i class=\"podlove-icon-spinner rotate\"></i>';\n\n\t\t\t$(\"li.active a\", $navigation).append(saving_icon);\n\n\t\t\t$.ajax(ajaxurl, {\n\t\t\t\tdataType: 'json',\n\t\t\t\ttype: 'POST',\n\t\t\t\tdata: {\n\t\t\t\t\tid: template_id,\n\t\t\t\t\ttitle: template_title,\n\t\t\t\t\tcontent: template_content,\n                    is_network: isNetwork,\n\t\t\t\t\taction: 'podlove-template-update',\n\t\t\t\t\tnonce: podlove_admin_global.nonce_ajax\n\t\t\t\t},\n\t\t\t\tsuccess: function(data, status, xhr) {\n\t\t\t\t\tsave_button.blur();\n\t\t\t\t\t$(\"li.active a i\", $navigation).remove();\n\t\t\t\t\tif (!data.success) {\n\t\t\t\t\t\tconsole.log(\"Error: Could not save template.\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttemplates[template_id].markAsSaved();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\n\t\t\te.preventDefault();\n\t\t};\n\n\t\tvar update_title = function(e) {\n\t\t\tvar $active_item = $(\"li.active a\", $navigation);\n\t\t\tvar template_id  = $active_item.data(\"id\");\n\t\t\tvar new_title    = $(this).val();\n\n\t\t\t// update cache\n\t\t\ttemplates[template_id].title = new_title;\n\t\t\ttemplates[template_id].markAsUnsaved();\n\n\t\t\t// update navigation element\n\t\t\t$(\".filename\", $active_item).html(new_title);\n\t\t};\n\n\t\tvar update_editor_cache = function () {\n\t\t\tvar $active_item = $(\"li.active a\", $navigation);\n\t\t\tvar template_id  = $active_item.data(\"id\");\n\t\t\tvar new_content  = editor.getSession().getValue();\n\n\t\t\t// update cache\n\t\t\tif (templates[template_id]) {\n\t\t\t\ttemplates[template_id].content = new_content;\n\t\t\t\ttemplates[template_id].markAsUnsaved();\n\t\t\t}\n\t\t};\n\n\t\tvar handle_editor_change = function () {\n\t\t\t// only track user input, *not* programmatical change\n\t\t\t// @see https://github.com/ajaxorg/ace/issues/503#issuecomment-44525640\n\t\t\tif (editor.curOp && editor.curOp.command.name) {\n\t\t\t\tupdate_editor_cache();\n\t\t\t}\n\t\t};\n\n\t\tvar add_template = function(e) {\n\n\t\t\t$.ajax(ajaxurl, {\n\t\t\t\tdataType: 'json',\n\t\t\t\ttype: 'POST',\n\t\t\t\tdata: { action: 'podlove-template-create', is_network: isNetwork, nonce: podlove_admin_global.nonce_ajax },\n\t\t\t\tsuccess: function(data, status, xhr) {\n\t\t\t\t\t$(\"ul\", $navigation)\n\t\t\t\t\t\t.append(\"<li><a href=\\\"#\\\" data-id=\\\"\" + data.id + \"\\\"><span class='filename'>new template</span>&nbsp;</a></li>\");\n\n\t\t\t\t\t$.proxy(activate_template, $(\"ul li:last a\", $navigation))();\n\n\t\t\t\t\t$title.focus();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\te.preventDefault();\n\t\t};\n\n\t\tvar delete_template = function(e) {\n\t\t\tvar template_id = $(\"li.active a\", $navigation).data('id');\n\n\t\t\tif (window.confirm(\"Delete template?\")) {\n\n\t\t\t\t$.ajax(ajaxurl, {\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\ttype: 'POST',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tid: template_id,\n                        is_network: isNetwork,\n\t\t\t\t\t\taction: 'podlove-template-delete',\n\t\t\t\t\t\tnonce: podlove_admin_global.nonce_ajax\n\t\t\t\t\t},\n\t\t\t\t\tsuccess: function(data, status, xhr) {\n\t\t\t\t\t\tif (data.success) {\n\t\t\t\t\t\t\t// delete navigation entry\n\t\t\t\t\t\t\t$(\"li a[data-id=\" + template_id + \"]\", $navigation)\n\t\t\t\t\t\t\t\t.closest(\"li\")\n\t\t\t\t\t\t\t\t.remove();\n\n\t\t\t\t\t\t\t// clear out editor\n\t\t\t\t\t\t\t$title.val(\"\");\n\t\t\t\t\t\t\teditor.getSession().setValue(\"\");\n\n\t\t\t\t\t\t\t// select other template, if available\n\t\t\t\t\t\t\t$(\"li:first a\", $navigation).click();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconsole.log(\"Error: Could not delete template.\");\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\n\t\t\te.preventDefault();\n\t\t};\n\n\t\t$title.keyup(update_title);\n\t\teditor.on(\"change\", handle_editor_change);\n\t\teditor.on(\"paste\", update_editor_cache);\n\n\t\t$navigation.on(\"click\", \"a[data-id]\", activate_template);\n\t\t$navigation.on(\"click\", \".add a\", add_template);\n\t\t$footer.on(\"click\", \"a.save\", save_template);\n\t\t$footer.on(\"click\", \".delete\", delete_template);\n\n\t\t// select first template on page load\n\t\t$(\"li:first a\", $navigation).click();\n\n\t});\n\n}(jQuery));\n"
  },
  {
    "path": "js/admin/tools/useragent.js",
    "content": "(function($){\n\n    var UserAgentRecalculator = function(id) {\n        var that = this;\n\n        this.button = $(id);\n        this.status = this.button.parent().find('.status');\n\n        this.button.on('click', function(e) {\n            e.preventDefault();\n            that.start();\n        })\n    };\n\n    UserAgentRecalculator.prototype.setStatus = function(status) {\n        this.progressbar.progressbar(\"value\", status);\n    }\n\n    UserAgentRecalculator.prototype.start = function() {\n\n        $(window).bind('beforeunload', function(){\n            return \"If you leave, \\\"User Agent Refresh\\\" will abort.\";\n        });\n\n        var label = $(\"#progressbar .progress-label\");\n        progressbar = $(\"#progressbar\");\n\n        progressbar.progressbar({\n            value: false,\n            change: function() {\n                label.text( progressbar.progressbar(\"value\") + \"%\" );\n            },\n            complete: function() {\n                label.text(\"Complete!\");\n            }\n        });\n\n        this.progressbar = progressbar;\n\n        this.setStatus(\"0\");\n        this.refresh_some(0);\n    }\n\n    UserAgentRecalculator.prototype.refresh_some = function(offset) {\n        var that = this;\n\n        $.ajax({\n            url: ajaxurl,\n            data: {\n                action: 'podlove-useragentrefresh',\n                offset: offset\n            },\n            dataType: 'json',\n            success: function(result) {\n                if (result.offset && result.offset < result.total) {\n                    var percent = result.offset / result.total * 100;\n                    that.setStatus(Math.round(percent));\n                    that.refresh_some(result.offset);\n                } else {\n                    that.setStatus(100);\n                    $(window).unbind(\"beforeunload\");\n                }\n            }\n        });\n    }\n\n    $(document).ready(function() {\n        var calc = new UserAgentRecalculator('#recalculate_useragents');\n    });\n\n}(jQuery));\n"
  },
  {
    "path": "js/package.json",
    "content": "{\n  \"scripts\": {\n    \"serve\": \"mix watch --mix-config=webpack.mix.js\",\n    \"build\": \"mix --mix-config=webpack.mix.js --production\"\n  },\n  \"devDependencies\": {\n    \"elliptic\": \">=6.6.1\",\n    \"laravel-mix\": \"6.0.49\",\n    \"node-forge\": \"^1.4.0\",\n    \"normalplaytime\": \"^1.0.4\",\n    \"vue\": \"^2.7.16\",\n    \"vue-loader\": \"^15.11.1\"\n  },\n  \"dependencies\": {\n    \"@popperjs/core\": \"2.11.8\",\n    \"axios\": \"^1.14.0\",\n    \"clipboard\": \"2.0.11\",\n    \"file-saver\": \"^2.0.5\",\n    \"v2-datepicker\": \"^3.1.1\",\n    \"vue-axios\": \"^2.1.5\",\n    \"vue-template-compiler\": \"^2.7.16\",\n    \"vuedraggable\": \"^2.24.3\"\n  },\n  \"overrides\": {\n    \"brace-expansion\": \"1.1.13\",\n    \"path-to-regexp\": \"0.1.13\"\n  }\n}\n"
  },
  {
    "path": "js/src/admin/dashboard_asset_validation.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n/**\n * Handles all logic in Dashboard Validation box.\n */\n(function($) {\n\tPODLOVE.DashboardAssetValidation = function(container) {\n\t\t// private\n\t\tvar o = {};\n\n\t\tfunction enable_validation() {\n\n\t\t\t$(\"#asset_status_dashboard td[data-media-file-id]\").click(function() {\n\t\t\t\tvar media_file_id = $(this).data(\"media-file-id\");\n\n\t\t\t\tif (!media_file_id)\n\t\t\t\t\treturn;\n\n\t\t\t\tvar $that = $(this);\n\t\t\t\tvar data = {\n\t\t\t\t\taction: 'podlove-file-update',\n\t\t\t\t\tfile_id: media_file_id\n\t\t\t\t};\n\n\t\t\t\t$(this).html('<i class=\"podlove-icon-spinner rotate\"></i>');\n\n\t\t\t\t// TODO: use REST API instead, then FileController can be deleted\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: data,\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tif (!result.active) {\n\t\t\t\t\t\t\t$that.html('<i class=\"clickable podlove-icon-minus\"></i>');\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tif (result.file_size > 0) {\n\t\t\t\t\t\t\t\t$that.html('<i class=\"clickable podlove-icon-ok\"></i>');\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t$that.html('<i class=\"clickable podlove-icon-remove\"></i>');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t});\n\n\t\t\t$(\"#revalidate_assets\").click(function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\t$(\"#asset_status_dashboard td[data-media-file-id]\").each(function() {\n\t\t\t\t\t$(this).click();\n\t\t\t\t});\n\n\t\t\t\treturn false;\n\t\t\t});\n\t\t}\n\n\t\t// public\n\t\tenable_validation();\n\n\t\treturn o;\n\t}\n}(jQuery));\n"
  },
  {
    "path": "js/src/admin/dashboard_feed_validation.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n/**\n * Handles all logic in Dashboard Validation box.\n */\n(function($) {\n\tPODLOVE.DashboardFeedValidation = function(container) {\n\t\t// private\n\t\tvar o = {};\n\n\t\tfunction enable_validation() {\n\n\t\t\t$(\"#dashboard_feed_info\").on('click', 'td[data-feed-id]', function() {\n\t\t\t\tvar feed_id = $(this).data(\"feed-id\");\n\t\t\t\tvar redirect = $(this).data(\"feed-redirect\");\n\n\t\t\t\tif (!feed_id)\n\t\t\t\t\treturn;\n\n\t\t\t\tvar $that = $(this);\n\t\t\t\tvar data = {\n\t\t\t\t\taction: 'podlove-validate-feed',\n\t\t\t\t\tfeed_id: feed_id,\n\t\t\t\t\tredirect: redirect\n\t\t\t\t};\n\n\t\t\t\t$(this).html('<i class=\"podlove-icon-spinner rotate\"></i>');\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: data,\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\t$that.html(result.validation_icon);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t});\n\n\t\t\t$(\"#revalidate_feeds\").click(function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\t$(\"#dashboard_feed_info td[data-feed-id]\").each(function() {\n\t\t\t\t\t$(this).click();\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\n\t\tfunction enable_information() {\n\n\t\t\t$(\"#dashboard_feed_info\").on('click', 'td[data-feed-id]', function() {\n\t\t\t\tvar feed_id = $(this).data(\"feed-id\");\n\t\t\t\tvar redirect = $(this).data(\"feed-redirect\");\n\n\t\t\t\tif (!feed_id)\n\t\t\t\t\treturn;\n\n\t\t\t\tvar column_latest_item \t= $(this).prev();\n\t\t\t\tvar column_size\t\t\t= column_latest_item.prev();\n\t\t\t\tvar column_modification = column_size.prev();\n\n\t\t\t\tvar data = {\n\t\t\t\t\taction: 'podlove-feed-info',\n\t\t\t\t\tfeed_id: feed_id,\n\t\t\t\t\tredirect: redirect\n\t\t\t\t};\n\n\t\t\t\tcolumn_latest_item.html('<i class=\"podlove-icon-spinner rotate\"></i>');\n\t\t\t\tcolumn_size.html('<i class=\"podlove-icon-spinner rotate\"></i>');\n\t\t\t\tcolumn_modification.html('<i class=\"podlove-icon-spinner rotate\"></i>');\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: data,\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tcolumn_size.html(result.size);\n\t\t\t\t\t\tcolumn_modification.html(result.last_modification);\n\t\t\t\t\t\tcolumn_latest_item.html(result.latest_item);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t});\n\t\t}\n\n\t\tenable_validation();\n\t\tenable_information();\n\n\t\t// fetch missing data on page load\n\t\t$(\"#dashboard_feed_info [data-needs-validation]\").each(function() {\n\t\t\t$(this).removeAttr('data-needs-validation').click();\n\t\t});\n\n\t\treturn o;\t\t\n\t}\n}(jQuery));"
  },
  {
    "path": "js/src/admin/episode.js",
    "content": "var PODLOVE = PODLOVE || {}\n\n/**\n * Handles all logic in Create/Edit Episode screen.\n */\n;(function ($) {\n  PODLOVE.Episode = function (container) {\n    var o = {}\n\n    o.slug_field = container.find('[name*=slug]')\n\n    $('#_podlove_meta_subtitle').count_characters({\n      limit: 255,\n      title: 'recommended maximum length: 255',\n    })\n    $('#_podlove_meta_summary').count_characters({\n      limit: 4000,\n      title: 'recommended maximum length: 4000',\n    })\n\n    $(document).on('click', '.subtitle_warning .close', function () {\n      $(this).closest('.subtitle_warning').remove()\n    })\n\n    $('#_podlove_meta_subtitle').keydown(function (e) {\n      // forbid return key\n      if (e.keyCode == 13) {\n        e.preventDefault()\n\n        if (!$('.subtitle_warning').length) {\n          $(this).after(\n            '<span class=\"subtitle_warning\">The subtitle has to be a single line. <span class=\"close\">(hide)</span></span>'\n          )\n        }\n\n        return false\n      }\n    })\n\n    var typewatch = (function () {\n      var timer = 0\n      return function (callback, ms) {\n        clearTimeout(timer)\n        timer = setTimeout(callback, ms)\n      }\n    })()\n\n    $.subscribe('/auphonic/production/status/results_imported', function (e, production) {\n      o.slug_field.trigger('slugHasChanged').data('auto-update', false)\n    })\n\n    var title_input = $('#titlewrap input')\n\n    title_input\n      .on('blur', function () {\n        title_input.trigger('titleHasChanged')\n      })\n      .on('keyup', function () {\n        typewatch(function () {\n          title_input.trigger('titleHasChanged')\n        }, 500)\n      })\n      .on('titleHasChanged', function () {\n        var title = $(this).val()\n\n        // update episode title\n        $('#_podlove_meta_title').attr('placeholder', title)\n      })\n      .trigger('titleHasChanged')\n\n    o.slug_field\n      .on('blur', function () {\n        o.slug_field.trigger('slugHasChanged')\n      })\n      .on('keyup', function () {\n        typewatch(function () {\n          o.slug_field.trigger('slugHasChanged')\n        }, 500)\n      })\n\n    return o\n  }\n})(jQuery)\n"
  },
  {
    "path": "js/src/admin/episode_asset_settings.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n/**\n * Handles all logic in Show Settings Screen.\n */\n(function($) {\n\tPODLOVE.EpisodeAssetSettings = function(container) {\n\t\t// private\n\t\tvar o = {};\n\n\t\tfunction make_asset_list_table_sortable() {\n\t\t\t$(\"table.episode_assets tbody\").sortable({\n\t\t\t\thandle: '.reorder-handle',\n\t\t\t\thelper: function(event, el) {\n\t\t\t\t\t\n\t\t\t\t\thelper = $(\"<div></div>\");\n\t\t\t\t\thelper.append( el.find(\".title\").html() );\n\t\t\t\t\thelper.css({\n\t\t\t\t\t\twidth: $(\"table.episode_assets\").width(),\n\t\t\t\t\t\tbackground: 'rgba(255,255,255,0.66)',\n\t\t\t\t\t\tboxSizing: 'border-box',\n\t\t\t\t\t\tpadding: 5\n\t\t\t\t\t});\n\n\t\t\t\t\treturn helper;\n\t\t\t\t},\n\t\t\t\tupdate: function( event, ui ) {\n\t\t\t\t\t// console.log(ui);\n\t\t\t\t\tvar prev = parseFloat(ui.item.prev().find(\".position\").val()),\n\t\t\t\t\t    next = parseFloat(ui.item.next().find(\".position\").val()),\n\t\t\t\t\t    new_position = 0;\n\n\t\t\t\t\tif ( ! prev ) {\n\t\t\t\t\t\tnew_position = next / 2;\n\t\t\t\t\t} else if ( ! next ) {\n\t\t\t\t\t\tnew_position = prev + 1;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnew_position = prev + (next - prev) / 2\n\t\t\t\t\t}\n\n\t\t\t\t\t// update UI\n\t\t\t\t\tui.item.find(\".position\").val(new_position);\n\n\t\t\t\t\t// persist\n\t\t\t\t\tvar data = {\n\t\t\t\t\t\taction: 'podlove-update-asset-position',\n\t\t\t\t\t\tasset_id: ui.item.find(\".asset_id\").val(),\n\t\t\t\t\t\tposition: new_position\n\t\t\t\t\t};\n\n\t\t\t\t\t$.ajax({ url: ajaxurl, data: data, dataType: 'json'\t});\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tfunction filter_file_formats_by_asset_type() {\n\t\t\t$('select[name=podlove_episode_asset_type]', container).on('change', function() {\n\t\t\t\tvar assetType = $(this).val();\n\t\t\t\tvar $fileTypeSelect = $(\"#podlove_episode_asset_file_type_id\");\n\t\t\t\tvar selectedValue = $fileTypeSelect.val();\n\n\t\t\t\t$(\"#option_storage option\").remove().appendTo($fileTypeSelect);\n\t\t\t\t$fileTypeSelect.find(\"option[data-type!='\" + assetType + \"']\").remove().appendTo($(\"#option_storage\"));\n\n\t\t\t\tif (!$fileTypeSelect.find(\"option[value='\" + selectedValue + \"']\").length) {\n\t\t\t\t\tvar $preferredOption = $fileTypeSelect.find(\"option[data-default-for-type='\" + assetType + \"']\").first();\n\n\t\t\t\t\tif (!$preferredOption.length) {\n\t\t\t\t\t\t$preferredOption = $fileTypeSelect.find(\"option\").first();\n\t\t\t\t\t}\n\n\t\t\t\t\t$fileTypeSelect.val($preferredOption.val());\n\t\t\t\t}\n\n\t\t\t\t$fileTypeSelect.change();\n\t\t\t}).change();\n\t\t}\n\n\t\tfunction slugify(text) {\n\n\t\t\ttext = text.trim();\n\t\t\t// replace non letter or digits by -\n\t\t\ttext = text.replace(/[^-\\w\\.\\~]/g, '-');\n\t\t\ttext = text.toLowerCase();\n\n\t\t\treturn text ? text : 'n-a';\n\t\t}\n\n\t\t// set default asset title\n\t\tfunction generate_default_episode_asset_title() {\n\t\t\t$('select[name*=file_type_id]', container).on('change', function() {\n\t\t\t\tvar $container = $(this).closest('table');\n\t\t\t\tvar $title = $container.find('[name*=\"title\"]');\n\t\t\t\tvar $name = $container.find('[name*=\"name\"]');\n\t\t\t\tvar fileFormatTitle = $(\"option:selected\", this).data('name');\n\t\t\t\tvar isCreateAction = ($container.closest(\"form\").find(\"input[name='action']\").val() === 'create');\n\n\t\t\t\tif (!fileFormatTitle)\n\t\t\t\t\treturn;\n\n\t\t\t\t// only prefill on unsaved assets\n\t\t\t\tif (!isCreateAction)\n\t\t\t\t\treturn;\n\n\t\t\t\t$title.val($(\"option:selected\", this).data('name'));\n\t\t\t\t$name.val(slugify($(\"option:selected\", this).data('name')));\n\t\t\t});\n\t\t}\n\n\t\tfunction generate_live_preview() {\n\t\t\t// handle preview updates\n\t\t\t$('input[name*=\"url_template\"]', container).on( 'keyup', o.update_preview );\n\t\t\t$('input[name*=\"suffix\"]', container).on( 'keyup', o.update_preview );\n\t\t\t$('#podlove_show_media_file_base_uri', container).on( 'keyup', o.update_preview );\n\t\t\t$('select[name=\"podlove_episode_asset_type\"]', container).on( 'change', o.update_preview );\n\t\t\t$('[name*=\"file_type_id\"]', container).on( 'change', o.update_preview );\n\t\t\to.update_preview();\n\t\t}\n\n\t\t// public\n\t\to.update_preview = function () {\n\t\t\t$('#url_preview', container).each(function() {\n\t\t\t\tvar template = $(\"#url_template\").html();\n\t\t\t\tvar $preview = $(\"#url_preview\");\n\t\t\t\tvar $container = $(this).closest('table');\n\n\t\t\t\tvar media_file_base_uri = $('#podlove_show_media_file_base_uri').val();\n\t\t\t\tvar episode_slug        = '<span style=\"font-style:italic; font-weight:100\">episode-slug</span>';\n\t\t\t\tvar suffix              = $('input[name*=\"suffix\"]').val();\n\n\t\t\t\tvar selected_file_type  = $container.find('[name*=\"file_type_id\"] option:selected').text();\n\t\t\t\tvar format_extension    = $container.find('[name*=\"file_type_id\"] option:selected').data('extension');\n\n\t\t\t\tif (!format_extension) {\n\t\t\t\t\t$preview.html('Please select file format');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\ttemplate = template.replace( '%media_file_base_url%', '<span style=\"color:grey\">' + media_file_base_uri );\n\t\t\t\ttemplate = template.replace( '%episode_slug%', episode_slug + \"</span>\" );\n\t\t\t\ttemplate = template.replace( '%suffix%', suffix );\n\t\t\t\ttemplate = template.replace( '%format_extension%', format_extension );\n\n\t\t\t\t$preview.html(template);\t\n\t\t\t});\n\t\t}\n\n\t\tgenerate_default_episode_asset_title();\n\t\tfilter_file_formats_by_asset_type();\n\t\tgenerate_live_preview();\n\t\tmake_asset_list_table_sortable();\n\n\t\treturn o;\n\t};\n}(jQuery));\n"
  },
  {
    "path": "js/src/admin/feed_settings.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n/**\n * Handles all logic in Feed Settings Screen.\n */\n(function($) {\n\tPODLOVE.FeedSettings = function(container) {\n\t\t// private\n\t\tvar o = {};\n\n\t\tfunction make_feed_list_table_sortable() {\n\t\t\t$(\"table.feeds tbody\").sortable({\n\t\t\t\thandle: '.reorder-handle',\n\t\t\t\thelper: function(event, el) {\n\t\t\t\t\t\n\t\t\t\t\thelper = $(\"<div></div>\");\n\t\t\t\t\thelper.append( el.find(\".title\").html() );\n\t\t\t\t\thelper.css({\n\t\t\t\t\t\twidth: $(\"table.feeds\").width(),\n\t\t\t\t\t\tbackground: 'rgba(255,255,255,0.66)',\n\t\t\t\t\t\tboxSizing: 'border-box',\n\t\t\t\t\t\tpadding: 5\n\t\t\t\t\t});\n\n\t\t\t\t\treturn helper;\n\t\t\t\t},\n\t\t\t\tupdate: function( event, ui ) {\n\t\t\t\t\t// console.log(ui);\n\t\t\t\t\tvar prev = parseFloat(ui.item.prev().find(\".position\").val()),\n\t\t\t\t\t    next = parseFloat(ui.item.next().find(\".position\").val()),\n\t\t\t\t\t    new_position = 0;\n\n\t\t\t\t\tif ( ! prev ) {\n\t\t\t\t\t\tnew_position = next / 2;\n\t\t\t\t\t} else if ( ! next ) {\n\t\t\t\t\t\tnew_position = prev + 1;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnew_position = prev + (next - prev) / 2\n\t\t\t\t\t}\n\n\t\t\t\t\t// update UI\n\t\t\t\t\tui.item.find(\".position\").val(new_position);\n\n\t\t\t\t\t// persist\n\t\t\t\t\tvar data = {\n\t\t\t\t\t\taction: 'podlove-update-feed-position',\n\t\t\t\t\t\tfeed_id: ui.item.find(\".feed_id\").val(),\n\t\t\t\t\t\tposition: new_position\n\t\t\t\t\t};\n\n\t\t\t\t\t$.ajax({ url: ajaxurl, data: data, dataType: 'json'\t});\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tfunction generate_slug_live_preview() {\n\t\t\t// handle preview updates\n\t\t\t$('#podlove_feed_slug', container).on( 'keyup', o.update_url_preview );\n\t\t\to.update_url_preview();\n\t\t}\n\n\t\tfunction generate_title_live_preview() {\n\t\t\t// handle preview updates\n\t\t\t$('#podlove_feed_append_name_to_podcast_title', container).change( function () {\n\t\t\t\to.update_title_preview();\n\t\t\t});\n\t\t\t$('#podlove_feed_name', container).change( function () {\n\t\t\t\to.update_title_preview();\n\t\t\t});\n\t\t\to.update_title_preview();\n\t\t}\n\n\t\tfunction manage_redirect_url_display() {\n\t\t\tvar http_status = $(\"#podlove_feed_redirect_http_status\").val();\n\n\t\t\tif (http_status > 0) {\n\t\t\t\t$(\".row_podlove_feed_redirect_url\").show();\n\t\t\t} else {\n\t\t\t\t$(\".row_podlove_feed_redirect_url\").hide();\n\t\t\t}\n\t\t}\n\n\t\tfunction slugify(text) {\n\n\t\t\ttext = text.trim();\n\t\t\t// replace non letter or digits by -\n\t\t\ttext = text.replace(/[^-\\w\\.\\~]/g, '-');\n\n\t\t\treturn text ? text : 'n-a';\n\t\t}\n\n\t\t// public\n\t\to.update_url_preview = function () {\n\t\t\t// remove trailing slash\n\t\t\tvar url = $(\"#feed_subscribe_url_preview\").data('url').substr(0, $(\"#feed_subscribe_url_preview\").data('url').length - 1);\n\t\t\t\n\t\t\t// remove slug if there is one\n\t\t\tif (url.substr(-4) !== \"feed\") {\n\t\t\t\turl = url.substr(0, url.lastIndexOf(\"/\"));\n\t\t\t}\n\n\t\t\tvar slug = slugify($(\"#podlove_feed_slug\").val());\n\t\t\tvar preview = \"\"\n\n\t\t\tif (slug == \"n-a\") {\n\t\t\t\tpreview = \"enter slug for preview\"\n\t\t\t} else {\n\t\t\t\tpreview = url + \"/\" + slug + \"/\"\n\t\t\t}\n\n\t\t\t$(\"#feed_subscribe_url_preview\").html(preview);\n\t\t}\n\n\t\to.update_title_preview = function () {\n\t\t\tif( $(\"#podlove_feed_append_name_to_podcast_title\").prop('checked') ) {\n\t\t\t\t$(\"#feed_title_preview_append\").html( ' (' + $(\"#podlove_feed_name\").val() + ')' );\n\t\t\t} else {\n\t\t\t\t$(\"#feed_title_preview_append\").html('');\n\t\t\t}\n\t\t}\n\n\t\tif ($(\"#feed_title_preview_append\").length && $(\"#podlove_feed_append_name_to_podcast_title\").length) {\n\t\t\tgenerate_title_live_preview();\n\t\t}\n\n\t\tif ($(\"#feed_subscribe_url_preview\").length && $(\"#podlove_feed_slug\").length) {\n\t\t\tgenerate_slug_live_preview();\n\t\t}\n\n\t\t$(\"#podlove_feed_redirect_http_status\").on(\"change\", function(){\n\t\t\tmanage_redirect_url_display();\n\t\t});\n\t\tmanage_redirect_url_display();\n\t\tmake_feed_list_table_sortable();\n\n\t\treturn o;\n\t};\n}(jQuery));\n"
  },
  {
    "path": "js/src/admin/jobs.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n/**\n * Handles all logic in Create/Edit Episode screen.\n * \n * @todo investigate: looks like there is trouble when a second UARJob is started while the first is still running.\n */\n(function($){\n\n    PODLOVE.Jobs = function() {};\n\n    PODLOVE.Jobs.create = function(name, args, callback) {\n        $.post(ajaxurl, {\n            action: 'podlove-job-create',\n            name: name,\n            args: args,\n            nonce: podlove_admin_global.nonce_ajax\n        }, 'json').done(function(job) {\n            // console.log(\"create job done\", job);\n\n            if (callback) {\n                callback(job);\n            }\n        });\n    };\n\n    PODLOVE.Jobs.getStatus = function(job_id, callback) {\n        $.getJSON(ajaxurl, {\n            action: 'podlove-job-get',\n            job_id: job_id\n        }).done(function(status) {\n            // console.log(\"job status\", job);\n\n            if (callback) {\n                callback(status);\n            }\n        });\n    }\n\n    PODLOVE.Jobs.Tools = function() {};\n\n    PODLOVE.Jobs.Tools.init = function() {\n        var wrapper = $(this)\n        var job_name = wrapper.data('job')\n        var button_text = wrapper.data('button-text')\n        var job_id = null;\n        var recent_job_id = wrapper.data('recent-job-id')\n        var job_args = wrapper.data('args') || {}\n        var timer = null;\n\n        var spinner = $(\"<i class=\\\"podlove-icon-spinner rotate\\\"></i>\");\n        var button = $(\"<button>\")\n            .addClass('button')\n            .html(button_text)\n        \n        var renderStatus = function(status) {\n\n            if (status.error) {\n                wrapper.html(status.error);\n                return;\n            }\n\n            var percent = 100 * (status.steps_progress / status.steps_total);\n\n            percent = Math.round(percent * 10) / 10;\n\n            if (!percent && status.steps_total > 0) {\n                wrapper\n                    .html(\" starting…\")\n                    .prepend(spinner.clone());\n            } else if (percent < 100 && status.steps_total > 0) {\n                wrapper\n                    .html(\" \" + percent + \"%\")\n                    .prepend(spinner.clone());\n            } else {\n                var t, datetime;\n\n                try {\n                    // try our best to parse the time but don't sweat if it fails\n                    t = status.updated_at.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})/);\n                    datetime = (new Date(Date.UTC(t[1], t[2]-1, t[3], t[4], t[5], t[6]))).toISOString();\n                } catch (e) {\n                   datetime = \"\";\n                }\n\n                wrapper\n                    .empty()\n                    .append(\"<small class=\\\"podlove-recent-job-info\\\">Finished in \" + Math.round(status.active_run_time) + \" seconds <time class=\\\"timeago\\\" datetime=\\\"\" + datetime + \"\\\"></time></small>.\")\n\n                $(\"time.timeago\").timeago();\n                renderButton();\n            }\n        };\n\n        var renderButton = function () {\n            var button_clone = button.clone();\n            wrapper.prepend(button_clone);\n            button_clone.on('click', btnClickHandler);\n        }\n\n        var update = function() {\n            PODLOVE.Jobs.getStatus(job_id, function(status) {\n                renderStatus(status);\n\n                if (status.error) {\n                    console.error(\"job error\", job_id, status.error);\n                    return;\n                }\n\n                // stop when done\n                if (parseInt(status.steps_progress, 10) >= parseInt(status.steps_total, 10))\n                    return;\n\n                timer = window.setTimeout(update, 3500);\n            });\n        };\n\n        var btnClickHandler = function(e) {\n            var job_spinner = spinner.clone();\n\n            PODLOVE.Jobs.create(job_name.split(\"-\").join(\"\\\\\"), job_args, function(job) {\n                job_id = job.job_id;\n                update();\n            });\n\n            wrapper\n                .empty()\n                .append(spinner.clone());\n        };\n\n        if (recent_job_id) {\n            job_id = recent_job_id;\n            update();\n        } else {\n            renderButton();\n        }\n    }\n\n    $(document).ready(function() {\n        $(\".podlove-job\").each(PODLOVE.Jobs.Tools.init);\n    })\n\n}(jQuery));\n\n"
  },
  {
    "path": "js/src/admin/jquery.count_characters.js",
    "content": "(function($){\n\n\t// twitter-like character counter\n\t$.fn.count_characters = function(options) {\n\n\t    var settings = $.extend( {\n\t        limit: 140,\n\t        on_negative: function($textarea, $counter_div) {\n\t            $counter_div.css('color', 'red')\n\t        },\n\t        on_positive: function($textarea, $counter_div) {\n\t            $counter_div.css('color', '#333')\n\t        },\n\t        title: ''\n\t    }, options);\n\n\t    this.filter('textarea').each(function() {\n\n\t    \tvar title = settings.title ? 'title=\"' + settings.title + '\"' : '';\n\t        $(this).after(\"<div class='character_counter'><span \" + title + \"></span></div>\")\n\n\t        $(this).keyup(function(e) {\n\t            var characters = $(this).val().length,\n\t                characters_left = settings.limit - characters,\n\t                $counter_div = $(this).next(\"div\");\n\n\t            $counter_div.find(\"span\").html(characters_left);\n\n\t            if (characters_left < 0) {\n\t                if (settings.on_negative)\n\t                    settings.on_negative($(this), $counter_div);\n\t            } else {\n\t                if (settings.on_positive)\n\t                    settings.on_positive($(this), $counter_div);\n\t            }\n\n\t        }).keyup();\n\n\t    });\n\n\t};\n\n})(jQuery);"
  },
  {
    "path": "js/src/admin/license.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n(function($) {\n\tPODLOVE.License = function(settings) {\n\t\tvar podlove_license_cc_get_image = function (allow_modifications, commercial_use) {\n\t\t\tvar banner_identifier_allowed_modification, banner_identifier_commercial_use;\n\n\t\t\tswitch (allow_modifications) {\n\t\t\t\tcase \"yes\" :\n\t\t\t\t\tbanner_identifier_allowed_modification = 1;\n\t\t\t\tbreak;\n\t\t\t\tcase \"yesbutshare\" :\n\t\t\t\t\tbanner_identifier_allowed_modification = 10;\n\t\t\t\tbreak;\n\t\t\t\tcase \"no\" :\n\t\t\t\t\tbanner_identifier_allowed_modification = 0;\n\t\t\t\tbreak;\n\t\t\t\tdefault :\n\t\t\t\t\tbanner_identifier_allowed_modification = 1;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tbanner_identifier_commercial_use = (commercial_use == \"no\") ? \"0\" : \"1\";\n\n\t\t\treturn banner_identifier_allowed_modification + \"_\" + banner_identifier_commercial_use;\n\t\t};\n\n\t\tvar podlove_change_url_preview_and_name_from_form = function(version_value, modification_value, commercial_use_value, jurisdiction_value) {\n\t\t\tif (!version_value || !modification_value || !commercial_use_value || !jurisdiction_value )\n\t\t\t\treturn;\n\n\t\t\tvar $that = $(this);\n\t\t\tvar data = {\n\t\t\t\taction: 'podlove-get-license-url',\n\t\t\t\tversion: version_value,\n\t\t\t\tmodification: modification_value,\n\t\t\t\tcommercial_use: commercial_use_value,\n\t\t\t\tjurisdiction: jurisdiction_value\n\t\t\t};\n\n\t\t\t$.ajax({\n\t\t\t\turl: ajaxurl,\n\t\t\t\tdata: data,\n\t\t\t\tdataType: 'json',\n\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t$(settings.license_url_field_id).val(result);\n\t\t\t\t\t$(\".podlove-license-link\").attr(\"href\", result);\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t// Redifining the required AJAX action (for license name)\n\t\t\tdata.action = 'podlove-get-license-name';\n\n\t\t\t$.ajax({\n\t\t\t\turl: ajaxurl,\n\t\t\t\tdata: data,\n\t\t\t\tdataType: 'json',\n\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t$(settings.license_name_field_id).val(result);\n\t\t\t\t\t$(\".podlove-license-link\").html(result);\n\t\t\t\t\t$(\".podlove-license-link\").attr(\"alt\", result);\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t$(\".podlove_podcast_license_image\").html(podlove_get_license_image(version_value, modification_value, commercial_use_value));\n\t\t\t$(\".row_podlove_podcast_license_preview\").show();\n\t\t};\n\n\t\tvar podlove_get_license_image = function(version_value, modification_value, commercial_use_value) {\n\t\t\tif (version_value == 'cc0') {\n\t\t\t\treturn '<img src=\"' + settings.plugin_url + '/images/cc/pd.png\" alt=\"\" />';\n\t\t\t} else if (version_value == 'pdmark') {\n\t\t\t\treturn '<img src=\"' + settings.plugin_url + '/images/cc/pdmark.png\" alt=\"\" />';\n\t\t\t} else {\n\t\t\t\treturn '<img src=\"' + settings.plugin_url + '/images/cc/' + podlove_license_cc_get_image(modification_value, commercial_use_value) + '.png\" alt=\"\" />';\n\t\t\t}\n\t\t};\n\n\t\tvar podlove_filter_license_selector = function(license_version) {\n\t\t\tswitch(license_version) {\n\t\t\t\tcase 'cc3':\n\t\t\t\t\t$(\"#license_cc_allow_modifications, #license_cc_allow_commercial_use, #license_cc_license_jurisdiction\").closest('div').show();\n\t\t\t\tbreak;\n\t\t\t\tcase 'cc4':\n\t\t\t\t\t$(\"#license_cc_allow_modifications, #license_cc_allow_commercial_use\").closest('div').show();\n\t\t\t\t\t$(\"#license_cc_license_jurisdiction\").closest('div').hide();\n\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\t$(\"#license_cc_allow_modifications, #license_cc_allow_commercial_use, #license_cc_license_jurisdiction\").closest('div').hide();\n\t\t\t\tbreak;\n\t\t\t}\n\t\t};\n\n\t\tvar podlove_populate_license_form = function(version_value, modification_value, commercial_use_value, jurisdiction_value) {\n\t\t\t$(\"#license_cc_version\").find('option[value=' + version_value + ']').attr('selected','selected');\n\t\t\t$(\"#license_cc_allow_modifications\").find('option[value=' + modification_value + ']').attr('selected','selected');\n\t\t\t$(\"#license_cc_allow_commercial_use\").find('option[value=' + commercial_use_value + ']').attr('selected','selected');\n\t\t\t$(\"#license_cc_license_jurisdiction\").find('option[value=' + jurisdiction_value + ']').attr('selected','selected');\n\n\t\t\tpodlove_filter_license_selector($(\"#license_cc_version\").val());\n\n\t\t\t$(\".podlove_podcast_license_image\").html(podlove_get_license_image(version_value, modification_value, commercial_use_value));\n\t\t\t\n\t\t\tvar data = {\n\t\t\t\taction: 'podlove-get-license-name',\n\t\t\t\tversion: version_value,\n\t\t\t\tmodification: modification_value,\n\t\t\t\tcommercial_use: commercial_use_value,\n\t\t\t\tjurisdiction: jurisdiction_value\n\t\t\t};\n\n\t\t\t$.ajax({\n\t\t\t\turl: ajaxurl,\n\t\t\t\tdata: data,\n\t\t\t\tdataType: 'json',\n\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t$(\".podlove-license-link\").html(result);\n\t\t\t\t\t$(\".podlove-license-link\").attr('href', $(\"#podlove_podcast_license_url\").val())\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tif( $(settings.license_name_field_id).val() == '' || $(settings.license_url_field_id).val() == '' )\n\t\t\t\t$(\".row_podlove_podcast_license_preview\").hide();\n\t\t};\n\n\t\t$(\"#podlove_cc_license_selector_toggle\").on( 'click', function() {\n\t\t\t$(this).find(\"._podlove_episode_list_triangle\").toggle();\n\t\t\t$(this).find(\"._podlove_episode_list_triangle_expanded\").toggle();\n\t\t\t$(\".row_podlove_cc_license_selector\").toggle();\n\t\t});\n\n\t\t$(\"#license_cc_version\").on( 'change', function () {\n\t\t\tpodlove_filter_license_selector($(this).val());\n\t\t} );\n\n\t\t$(settings.license_url_field_id).on( 'change', function() {\n\t\t\tif( $(this).val().indexOf('creativecommons.org') !== -1 ) {\n\t\t\t\tvar data = {\n\t\t\t\t\taction: 'podlove-get-license-parameters-from-url',\n\t\t\t\t\turl: $(this).val()\n\t\t\t\t};\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: data,\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tpodlove_populate_license_form(\n\t\t\t\t\t\t\tresult.version,\n\t\t\t\t\t\t\tresult.modification,\n\t\t\t\t\t\t\tresult.commercial_use,\n\t\t\t\t\t\t\tresult.jurisdiction\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t$(\".podlove_podcast_license_image\").html('');\n\t\t\t\t$(\".podlove-license-link\").html( $(settings.license_name_field_id).val() );\n\t\t\t\t$(\".podlove-license-link\").attr(\"href\", $(this).val() );\n\t\t\t}\n\t\t\t$(\".row_podlove_podcast_license_preview\").show();\n\t\t});\n\n\t\t$(settings.license_name_field_id).on( 'change', function() {\n\t\t\t$(\".podlove-license-link\").html( $(this).val() );\n\t\t\t$(\".row_podlove_podcast_license_preview\").show();\n\t\t});\n\n\t\t$(\"#license_cc_allow_modifications, #license_cc_allow_commercial_use, #license_cc_license_jurisdiction, #license_cc_version\").on( 'change', function() {\n\t\t\tpodlove_change_url_preview_and_name_from_form(\n\t\t\t\t$(\"#license_cc_version\").val(),\n\t\t\t\t$(\"#license_cc_allow_modifications\").val(),\n\t\t\t\t$(\"#license_cc_allow_commercial_use\").val(),\n\t\t\t\t$(\"#license_cc_license_jurisdiction\").val()\n\t\t\t);\n\t\t});\n\n\t\t$(document).ready(function() {\n\t\t\tif( $(settings.license_name_field_id).val() !== '' || $(settings.license_url_field_id).val() !== '' )\n\t\t\t\tpodlove_populate_license_form( settings.license.version, settings.license.modification, settings.license.commercial_use, settings.license.jurisdiction );\n\n\t\t\tif( $(settings.license_name_field_id).val() == '' || $(settings.license_url_field_id).val() == '' )\n\t\t\t\t$(\".row_podlove_podcast_license_preview\").hide();\n\t\t});\n\t}\n\n}(jQuery));\n\n"
  },
  {
    "path": "js/src/admin/md5.js",
    "content": "/*\nCryptoJS v3.1.2\ncode.google.com/p/crypto-js\n(c) 2009-2013 by Jeff Mott. All rights reserved.\ncode.google.com/p/crypto-js/wiki/License\n*/\nvar CryptoJS=CryptoJS||function(s,p){var m={},l=m.lib={},n=function(){},r=l.Base={extend:function(b){n.prototype=this;var h=new n;b&&h.mixIn(b);h.hasOwnProperty(\"init\")||(h.init=function(){h.$super.init.apply(this,arguments)});h.init.prototype=h;h.$super=this;return h},create:function(){var b=this.extend();b.init.apply(b,arguments);return b},init:function(){},mixIn:function(b){for(var h in b)b.hasOwnProperty(h)&&(this[h]=b[h]);b.hasOwnProperty(\"toString\")&&(this.toString=b.toString)},clone:function(){return this.init.prototype.extend(this)}},\nq=l.WordArray=r.extend({init:function(b,h){b=this.words=b||[];this.sigBytes=h!=p?h:4*b.length},toString:function(b){return(b||t).stringify(this)},concat:function(b){var h=this.words,a=b.words,j=this.sigBytes;b=b.sigBytes;this.clamp();if(j%4)for(var g=0;g<b;g++)h[j+g>>>2]|=(a[g>>>2]>>>24-8*(g%4)&255)<<24-8*((j+g)%4);else if(65535<a.length)for(g=0;g<b;g+=4)h[j+g>>>2]=a[g>>>2];else h.push.apply(h,a);this.sigBytes+=b;return this},clamp:function(){var b=this.words,h=this.sigBytes;b[h>>>2]&=4294967295<<\n32-8*(h%4);b.length=s.ceil(h/4)},clone:function(){var b=r.clone.call(this);b.words=this.words.slice(0);return b},random:function(b){for(var h=[],a=0;a<b;a+=4)h.push(4294967296*s.random()|0);return new q.init(h,b)}}),v=m.enc={},t=v.Hex={stringify:function(b){var a=b.words;b=b.sigBytes;for(var g=[],j=0;j<b;j++){var k=a[j>>>2]>>>24-8*(j%4)&255;g.push((k>>>4).toString(16));g.push((k&15).toString(16))}return g.join(\"\")},parse:function(b){for(var a=b.length,g=[],j=0;j<a;j+=2)g[j>>>3]|=parseInt(b.substr(j,\n2),16)<<24-4*(j%8);return new q.init(g,a/2)}},a=v.Latin1={stringify:function(b){var a=b.words;b=b.sigBytes;for(var g=[],j=0;j<b;j++)g.push(String.fromCharCode(a[j>>>2]>>>24-8*(j%4)&255));return g.join(\"\")},parse:function(b){for(var a=b.length,g=[],j=0;j<a;j++)g[j>>>2]|=(b.charCodeAt(j)&255)<<24-8*(j%4);return new q.init(g,a)}},u=v.Utf8={stringify:function(b){try{return decodeURIComponent(escape(a.stringify(b)))}catch(g){throw Error(\"Malformed UTF-8 data\");}},parse:function(b){return a.parse(unescape(encodeURIComponent(b)))}},\ng=l.BufferedBlockAlgorithm=r.extend({reset:function(){this._data=new q.init;this._nDataBytes=0},_append:function(b){\"string\"==typeof b&&(b=u.parse(b));this._data.concat(b);this._nDataBytes+=b.sigBytes},_process:function(b){var a=this._data,g=a.words,j=a.sigBytes,k=this.blockSize,m=j/(4*k),m=b?s.ceil(m):s.max((m|0)-this._minBufferSize,0);b=m*k;j=s.min(4*b,j);if(b){for(var l=0;l<b;l+=k)this._doProcessBlock(g,l);l=g.splice(0,b);a.sigBytes-=j}return new q.init(l,j)},clone:function(){var b=r.clone.call(this);\nb._data=this._data.clone();return b},_minBufferSize:0});l.Hasher=g.extend({cfg:r.extend(),init:function(b){this.cfg=this.cfg.extend(b);this.reset()},reset:function(){g.reset.call(this);this._doReset()},update:function(b){this._append(b);this._process();return this},finalize:function(b){b&&this._append(b);return this._doFinalize()},blockSize:16,_createHelper:function(b){return function(a,g){return(new b.init(g)).finalize(a)}},_createHmacHelper:function(b){return function(a,g){return(new k.HMAC.init(b,\ng)).finalize(a)}}});var k=m.algo={};return m}(Math);\n(function(s){function p(a,k,b,h,l,j,m){a=a+(k&b|~k&h)+l+m;return(a<<j|a>>>32-j)+k}function m(a,k,b,h,l,j,m){a=a+(k&h|b&~h)+l+m;return(a<<j|a>>>32-j)+k}function l(a,k,b,h,l,j,m){a=a+(k^b^h)+l+m;return(a<<j|a>>>32-j)+k}function n(a,k,b,h,l,j,m){a=a+(b^(k|~h))+l+m;return(a<<j|a>>>32-j)+k}for(var r=CryptoJS,q=r.lib,v=q.WordArray,t=q.Hasher,q=r.algo,a=[],u=0;64>u;u++)a[u]=4294967296*s.abs(s.sin(u+1))|0;q=q.MD5=t.extend({_doReset:function(){this._hash=new v.init([1732584193,4023233417,2562383102,271733878])},\n_doProcessBlock:function(g,k){for(var b=0;16>b;b++){var h=k+b,w=g[h];g[h]=(w<<8|w>>>24)&16711935|(w<<24|w>>>8)&4278255360}var b=this._hash.words,h=g[k+0],w=g[k+1],j=g[k+2],q=g[k+3],r=g[k+4],s=g[k+5],t=g[k+6],u=g[k+7],v=g[k+8],x=g[k+9],y=g[k+10],z=g[k+11],A=g[k+12],B=g[k+13],C=g[k+14],D=g[k+15],c=b[0],d=b[1],e=b[2],f=b[3],c=p(c,d,e,f,h,7,a[0]),f=p(f,c,d,e,w,12,a[1]),e=p(e,f,c,d,j,17,a[2]),d=p(d,e,f,c,q,22,a[3]),c=p(c,d,e,f,r,7,a[4]),f=p(f,c,d,e,s,12,a[5]),e=p(e,f,c,d,t,17,a[6]),d=p(d,e,f,c,u,22,a[7]),\nc=p(c,d,e,f,v,7,a[8]),f=p(f,c,d,e,x,12,a[9]),e=p(e,f,c,d,y,17,a[10]),d=p(d,e,f,c,z,22,a[11]),c=p(c,d,e,f,A,7,a[12]),f=p(f,c,d,e,B,12,a[13]),e=p(e,f,c,d,C,17,a[14]),d=p(d,e,f,c,D,22,a[15]),c=m(c,d,e,f,w,5,a[16]),f=m(f,c,d,e,t,9,a[17]),e=m(e,f,c,d,z,14,a[18]),d=m(d,e,f,c,h,20,a[19]),c=m(c,d,e,f,s,5,a[20]),f=m(f,c,d,e,y,9,a[21]),e=m(e,f,c,d,D,14,a[22]),d=m(d,e,f,c,r,20,a[23]),c=m(c,d,e,f,x,5,a[24]),f=m(f,c,d,e,C,9,a[25]),e=m(e,f,c,d,q,14,a[26]),d=m(d,e,f,c,v,20,a[27]),c=m(c,d,e,f,B,5,a[28]),f=m(f,c,\nd,e,j,9,a[29]),e=m(e,f,c,d,u,14,a[30]),d=m(d,e,f,c,A,20,a[31]),c=l(c,d,e,f,s,4,a[32]),f=l(f,c,d,e,v,11,a[33]),e=l(e,f,c,d,z,16,a[34]),d=l(d,e,f,c,C,23,a[35]),c=l(c,d,e,f,w,4,a[36]),f=l(f,c,d,e,r,11,a[37]),e=l(e,f,c,d,u,16,a[38]),d=l(d,e,f,c,y,23,a[39]),c=l(c,d,e,f,B,4,a[40]),f=l(f,c,d,e,h,11,a[41]),e=l(e,f,c,d,q,16,a[42]),d=l(d,e,f,c,t,23,a[43]),c=l(c,d,e,f,x,4,a[44]),f=l(f,c,d,e,A,11,a[45]),e=l(e,f,c,d,D,16,a[46]),d=l(d,e,f,c,j,23,a[47]),c=n(c,d,e,f,h,6,a[48]),f=n(f,c,d,e,u,10,a[49]),e=n(e,f,c,d,\nC,15,a[50]),d=n(d,e,f,c,s,21,a[51]),c=n(c,d,e,f,A,6,a[52]),f=n(f,c,d,e,q,10,a[53]),e=n(e,f,c,d,y,15,a[54]),d=n(d,e,f,c,w,21,a[55]),c=n(c,d,e,f,v,6,a[56]),f=n(f,c,d,e,D,10,a[57]),e=n(e,f,c,d,t,15,a[58]),d=n(d,e,f,c,B,21,a[59]),c=n(c,d,e,f,r,6,a[60]),f=n(f,c,d,e,z,10,a[61]),e=n(e,f,c,d,j,15,a[62]),d=n(d,e,f,c,x,21,a[63]);b[0]=b[0]+c|0;b[1]=b[1]+d|0;b[2]=b[2]+e|0;b[3]=b[3]+f|0},_doFinalize:function(){var a=this._data,k=a.words,b=8*this._nDataBytes,h=8*a.sigBytes;k[h>>>5]|=128<<24-h%32;var l=s.floor(b/\n4294967296);k[(h+64>>>9<<4)+15]=(l<<8|l>>>24)&16711935|(l<<24|l>>>8)&4278255360;k[(h+64>>>9<<4)+14]=(b<<8|b>>>24)&16711935|(b<<24|b>>>8)&4278255360;a.sigBytes=4*(k.length+1);this._process();a=this._hash;k=a.words;for(b=0;4>b;b++)h=k[b],k[b]=(h<<8|h>>>24)&16711935|(h<<24|h>>>8)&4278255360;return a},clone:function(){var a=t.clone.call(this);a._hash=this._hash.clone();return a}});r.MD5=t._createHelper(q);r.HmacMD5=t._createHmacHelper(q)})(Math);"
  },
  {
    "path": "js/src/admin/media.js",
    "content": "var PODLOVE = PODLOVE || {};\nPODLOVE.media = PODLOVE.media || {};\n\n(function($) {\n\t\"use strict\";\n\n\tvar args;\n\n\tPODLOVE.media.init =  function() {\n\t\t$(\".podlove-media-upload-wrap\").each(function() {\n\t\t\tPODLOVE.media.init_field($(this));\n\t\t});\n\t};\n\n\tPODLOVE.media.init_field = function(container) {\n\t\tvar $upload_link = $(\".podlove-media-upload\", container),\n\t\t    options = $upload_link.data(),\n\t\t\tparams  = {\n\t\t\t\tframe:   options.frame,\n\t\t\t\tlibrary: { type: options.type },\n\t\t\t\tbutton:  { text: options.button },\n\t\t\t\tclassName: options['class'],\n\t\t\t\ttitle: options.title\n\t\t\t}\n\t\t;\n\n\t\tif (typeof options.state != \"undefined\" ) params.state = options.state;\n\n\t\toptions.input_target = $('#'+options.target);\n\t\toptions.container = container;\n\n\t\tif (options.preview) {\n\t\t\toptions.input_target.on(\"change\", function() {\n\t\t\t\tPODLOVE.media.render_preview(options.container);\n\t\t\t});\n\t\t}\n\n\t\t// set size that is selected by default\n\t\tif (options.size) {\n\t\t\twp.media.view.settings.defaultProps.size = options.size;\n\t\t}\n\n\t\targs = options;\n\n\t\tvar file_frame = wp.media(params);\n\n\t\tfile_frame.states.add([\n\t\t\tnew wp.media.controller.Library({\n\t\t\t\tid:         'podlove_select_single_image',\n\t\t\t\tpriority:   20,\n\t\t\t\ttoolbar:    'select',\n\t\t\t\tfilterable: 'uploaded',\n\t\t\t\t// library:    wp.media.query( file_frame.options.library ),\n\t\t\t\tmultiple:   args.multiple,\n\t\t\t\teditable:   true,\n\t\t\t\tdisplaySettings: true,\n\t\t\t\tallowLocalEdits: true\n\t\t\t}),\n\t\t]);\n\n\t\tfile_frame.on('select update insert', function() { PODLOVE.media.insert(file_frame, options); });\n\n\t\t$upload_link.on('click', function() {\n\t\t\tfile_frame.open();\n\t\t});\n\n\t\tcontainer.on('click', '.podlove_reset_image', {options: options}, PODLOVE.media.reset);\n\n\t\tPODLOVE.media.render_preview(container);\n\t}\n\n\tPODLOVE.media.reset = function(e) {\n\t\tvar options = e.data.options;\n\n\t\toptions.container.find(\".podlove_preview_pic\").empty().hide();\n\t\toptions.input_target.val(\"\");\n\t};\n\n\tfunction get_gravatar(email) {\n\t\tif ( email.indexOf(\"@\") == -1 ) {\n\t\t\treturn email;\n\t\t} else {\n\t\t\treturn 'https://www.gravatar.com/avatar/' + CryptoJS.MD5( email ) + '&s=400';\n\n\t\t}\n\t}\n\n\tPODLOVE.media.render_preview = function(wrapper) {\n\t\tvar preview  = $(\".podlove_preview_pic\", wrapper)[0],\n\t\t    $input   = $(\"input\", wrapper).first(),\n\t\t    url      = $input.val();\n\n\t    if (args.allowGravatar) {\n\t    \turl = get_gravatar(url);\n\t    }\n\n\t\tif (!url) {\n\t\t\treturn;\n\t\t}\n\n\t\t$(\".podlove_preview_pic\", wrapper).empty().hide();\n\n\t\tvar image = document.createElement('img');\n\t\timage.width = 300;\n\t\timage.src = url;\n\n\t\tvar remove = document.createElement('button');\n\t\tremove.className = 'podlove_reset_image button';\n\t\tremove.appendChild(document.createTextNode('remove'));\n\n\t\tpreview.appendChild(image);\n\t\tpreview.appendChild(remove);\n\t\tpreview.style.display = \"block\";\n\t};\n\n\tPODLOVE.media.insert = function(file_frame , options) {\n\t\tvar state\t\t= file_frame.state(),\n\t\t\tselection\t= state.get('selection').first().toJSON(),\n\t\t\tvalue\t\t= selection.id,\n\t\t\tfetch_val   = typeof options.fetch != 'undefined' ? fetch_val = options.fetch : false\n\n\t\t/*fetch custom val like url*/\n\t\tif (fetch_val) {\n\t\t\tvalue = state.get('selection').map( function( attachment ) {\n\t\t\t\tvar element = attachment.toJSON();\n\n\t\t\t\tif (fetch_val == 'url') {\n\t\t\t\t\tvar display = state.display( attachment ).toJSON();\n\n\t\t\t\t\tif (element.sizes && element.sizes[display.size] && element.sizes[display.size].url) {\n\t\t\t\t\t\treturn element.sizes[display.size].url;\n\t\t\t\t\t} else if (element.url) {\n\t\t\t\t\t\treturn element.url;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\t// change the target input value\n\t\toptions.input_target.val(value).trigger('change')\n\n        document.getElementById(options.target).dispatchEvent(new Event('change', { 'bubbles': true }))\n\n\t\t// trigger event in case it is necessary (uploads)\n\t\tif (typeof options.trigger != \"undefined\") {\n\t\t\t$(\"body\").trigger(options.trigger, [selection, options]);\n\t\t}\n\t}\n\n\t$(document).ready(function () {\n\t\tPODLOVE.media.init();\n\t});\n\n})(jQuery);\n"
  },
  {
    "path": "js/src/admin/podlove_data_table.js",
    "content": "(function($) {\n\n\t/**\n\t * Podlove Data Table.\n\t *\n\t * jQuery plugin for dynamic data tables.\n\t *\n\t * Usage:\n\t * \t$(selector).podloveDataTable(options);\n\t *\n\t * Options:\n\t * \trowTemplate:    selector for html row template, e.g.\"#podlove-table-template\"\n\t *\tdeleteHandle:   selector for row delete element\n\t *\tsortableHandle: selector for row move/sort element\n\t *\taddRowHandle:   selector for \"add row\" element\n\t *\tdataPresets:    list of objects, must have an id attribute, e.g. [{id: 1, title: \"foo\"}]\n\t *\tdata:           list of objects, representing existing rows in the table, must have an id attribute\n\t *\tonRowLoad:      callback function. called when rowTemplate is loaded\n\t *\tonRowAdd:       callback function. called after rowTemplate was added to the DOM\n\t *\tonRowDelete:    callback function. called when a row was deleted from the DOM\n\t *\tonRowMove:      callback function. called when the position of a row has changed\n\t */\n\t$.fn.podloveDataTable = function(options) {\n\n\t\tvar $this = $(this);\n\n\t\t// set default options\n\t\tvar settings = $.extend({}, $.fn.podloveDataTable.defaults, options);\n\n\t\tfunction fetch_object(object_id) {\n\t\t\tobject_id = parseInt(object_id, 10);\n\n\t\t\treturn $.grep(settings.dataPresets, function(object, index) {\n\t\t\t\treturn parseInt(object.id, 10) === object_id;\n\t\t\t})[0]; // Using [0] as the returned element has multiple indexes\n\t\t}\n\n\t\tfunction add_object_row(object_index, object, entry, initializing) {\n\t\t\tvar row = $(settings.rowTemplate).html();\n\t\t\tvar obj = {row: row, object: object, entry: entry};\n\n\t\t\tsettings.onRowLoad.call(this, obj, initializing);\n\t\t\t$(\"tbody\", $this).append($(obj.row).data('object-id', object_index));\n\t\t\tsettings.onRowAdd.call(this, obj, initializing);\n\t\t}\n\n\t\t// add existing data\n\t\t$.each(settings.data, function(index, entry) {\n\t\t\tadd_object_row(index, fetch_object(entry.id), entry, true);\n\t\t});\n\n\t\t// fix td width\n\t\t$(\"tbody td\", $this).each(function(){\n\t\t    $(this).css('width', $(this).width() +'px');\n\t\t});\n\n\t\tif (settings.addRowHandle) {\n\t\t\t$(document).on('click', settings.addRowHandle, function() {\n\t\t\t\tadd_object_row(0, {}, \"\", \"\");\n\t\t\t});\n\t\t}\n\n\t\tif (settings.deleteHandle) {\n\t\t\t$this.on('click', settings.deleteHandle, function() {\n\t\t\t\tvar tr = $(this).closest(\"tr\");\n\t\t\t\tsettings.onRowDelete.call(this, tr);\n\t\t\t\ttr.remove();\n\t\t\t});\n\t\t}\n\n\t\tif (settings.sortableHandle) {\n\t\t\t$(\"tbody\", $this).sortable({\n\t\t\t\thandle: settings.sortableHandle,\n\t\t\t\thelper: function(e, tr) {\n\t\t\t\t    var $originals = tr.children();\n\t\t\t\t    var $helper = tr.clone();\n\t\t\t\t    $helper.children().each(function(index) {\n\t\t\t\t    \t// Set helper cell sizes to match the original sizes\n\t\t\t\t    \t$(this).width($originals.eq(index).width());\n\t\t\t\t    });\n\t\t\t\t    return $helper.css({\n\t\t\t\t    \tbackground: '#EAEAEA'\n\t\t\t\t    });\n\t\t\t\t},\n\t\t\t\tupdate: settings.onRowMove\n\t\t\t});\n\t\t};\n\n\t\treturn $this;\n\t};\n\n\t$.fn.podloveDataTable.defaults = {\n\t\trowTemplate: \"#podlove-table-template\",\n\t\tdeleteHandle: \"\",\n\t\tsortableHandle: \"\",\n\t\taddRowHandle: \"\",\n\t\tdataPresets: [],\n\t\tdata: [],\n\t\tonRowLoad:   function() {},\n\t\tonRowAdd:    function() {},\n\t\tonRowDelete: function() {},\n\t\tonRowMove:   function() {}\n\t};\n\n}(jQuery));"
  },
  {
    "path": "js/src/admin/post_title_autogenerate.js",
    "content": "jQuery(document).ready(function($) {\n    if (PODLOVE.override_post_title && PODLOVE.override_post_title.enabled) {\n        podlove_init_title_override();\n    }\n\n    var $titlediv, $titlewrap, $titleinput, $numberinput, $itunestitleinput;\n\n    function podlove_init_title_override() {\n        $titlediv = $(\"#titlediv\");\n        $titlewrap = $(\"#titlewrap\");\n        $titleinput = $(\"input[name='post_title']\", $titlewrap);\n        $numberinput = $(\"#_podlove_meta_number\");\n        $itunestitleinput = $(\"#_podlove_meta_title\");\n\n        $titleinput.attr('readonly', true);\n        $titleinput.css('background-color', '#eee');\n        $(\"#title-prompt-text\").hide();\n\n        podlove_update_episode_title();\n\n        $numberinput.on('keyup change', podlove_update_episode_title);\n        $itunestitleinput.on('keyup change', podlove_update_episode_title);\n    }\n\n    function podlove_update_episode_title() {\n        var template = PODLOVE.override_post_title.template;\n\n        var mnemonic = PODLOVE.override_post_title.mnemonic;\n        var episode_number = $numberinput.val();\n        var episode_title = $itunestitleinput.val();\n\n        var padLeft = function(nr, n, str){\n            if (String(nr).length < n) {\n                return Array(n-String(nr).length+1).join(str||'0')+nr;\n            } else {\n                return nr;\n            }\n        }\n\n        var title = template;\n        if (episode_title) {\n            title = title.replace('%mnemonic%', mnemonic);\n            title = title.replace('%episode_number%', padLeft(episode_number, PODLOVE.override_post_title.episode_padding, '0'));\n            title = title.replace('%season_number%', PODLOVE.override_post_title.season_number);\n            title = title.replace('%episode_title%', episode_title);\n            $titleinput.val(title);\n        } else {\n            $titleinput.attr('placeholder', PODLOVE.override_post_title.placeholder)\n        }\n\n        $(\"#titlewrap input\").trigger('titleHasChanged');\n    }\n});\n"
  },
  {
    "path": "js/src/admin/protected_feed.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n(function($) {\n\tPODLOVE.ProtectFeed = function() {\n\t\tvar $protection = $(\"#podlove_feed_protected\"),\n\t\t\t$protection_row = $(\"tr.row_podlove_feed_protection_type\"),\n\t\t\t$protection_type = $(\"#podlove_feed_protection_type\"),\n\t\t\t$credentials = $(\"tr.row_podlove_feed_protection_password,tr.row_podlove_feed_protection_user\");\n\n\t\tvar protectionIsActive = function() {\n\t\t\treturn $protection.is(\":checked\");\n\t\t};\n\n\t\tvar isCustomLogin = function() {\n\t\t\treturn $protection_type.val() == \"0\";\n\t\t};\n\n\t\tif (protectionIsActive()) {\n\t\t\t$protection_row.show();\n\t\t}\n\t\t\n\t\tif (protectionIsActive() && isCustomLogin()) {\n\t\t\t$credentials.show();\n\t\t}\n\n\t\t$(\"#podlove_feed_protected\").on(\"change\", function() {\n\t\t\tif (protectionIsActive()) {\n\t\t\t\t$protection_row.show();\n\t\t\t\tif (isCustomLogin()) {\n\t\t\t\t\t$credentials.show();\n\t\t\t\t} \n\t\t\t} else {\n\t\t\t\t$protection_row.hide();\n\t\t\t\t$credentials.hide();\n\t\t\t}\n\t\t});\t\n\n\t\t$protection_type.change(function() {\n\t\t\tif (protectionIsActive() && isCustomLogin()) {\n\t\t\t\t$credentials.show();\n\t\t\t} else {\n\t\t\t\t$credentials.hide();\n\t\t\t}\n\t\t});\n\t}\n}(jQuery));"
  },
  {
    "path": "js/src/admin/timeago.jquery.js",
    "content": "/**\n * Timeago is a jQuery plugin that makes it easy to support automatically\n * updating fuzzy timestamps (e.g. \"4 minutes ago\" or \"about 1 day ago\").\n *\n * @name timeago\n * @version 1.5.2\n * @requires jQuery v1.2.3+\n * @author Ryan McGeary\n * @license MIT License - http://www.opensource.org/licenses/mit-license.php\n *\n * For usage and examples, visit:\n * http://timeago.yarp.com/\n *\n * Copyright (c) 2008-2015, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)\n */\n\n(function (factory) {\n  if (typeof define === 'function' && define.amd) {\n    // AMD. Register as an anonymous module.\n    define(['jquery'], factory);\n  } else if (typeof module === 'object' && typeof module.exports === 'object') {\n    factory(require('jquery'));\n  } else {\n    // Browser globals\n    factory(jQuery);\n  }\n}(function ($) {\n  $.timeago = function(timestamp) {\n    if (timestamp instanceof Date) {\n      return inWords(timestamp);\n    } else if (typeof timestamp === \"string\") {\n      return inWords($.timeago.parse(timestamp));\n    } else if (typeof timestamp === \"number\") {\n      return inWords(new Date(timestamp));\n    } else {\n      return inWords($.timeago.datetime(timestamp));\n    }\n  };\n  var $t = $.timeago;\n\n  $.extend($.timeago, {\n    settings: {\n      refreshMillis: 60000,\n      allowPast: true,\n      allowFuture: false,\n      localeTitle: false,\n      cutoff: 0,\n      autoDispose: true,\n      strings: {\n        prefixAgo: null,\n        prefixFromNow: null,\n        suffixAgo: \"ago\",\n        suffixFromNow: \"from now\",\n        inPast: 'any moment now',\n        seconds: \"less than a minute\",\n        minute: \"about a minute\",\n        minutes: \"%d minutes\",\n        hour: \"about an hour\",\n        hours: \"about %d hours\",\n        day: \"a day\",\n        days: \"%d days\",\n        month: \"about a month\",\n        months: \"%d months\",\n        year: \"about a year\",\n        years: \"%d years\",\n        wordSeparator: \" \",\n        numbers: []\n      }\n    },\n\n    inWords: function(distanceMillis) {\n      if (!this.settings.allowPast && ! this.settings.allowFuture) {\n          throw 'timeago allowPast and allowFuture settings can not both be set to false.';\n      }\n\n      var $l = this.settings.strings;\n      var prefix = $l.prefixAgo;\n      var suffix = $l.suffixAgo;\n      if (this.settings.allowFuture) {\n        if (distanceMillis < 0) {\n          prefix = $l.prefixFromNow;\n          suffix = $l.suffixFromNow;\n        }\n      }\n\n      if (!this.settings.allowPast && distanceMillis >= 0) {\n        return this.settings.strings.inPast;\n      }\n\n      var seconds = Math.abs(distanceMillis) / 1000;\n      var minutes = seconds / 60;\n      var hours = minutes / 60;\n      var days = hours / 24;\n      var years = days / 365;\n\n      function substitute(stringOrFunction, number) {\n        var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;\n        var value = ($l.numbers && $l.numbers[number]) || number;\n        return string.replace(/%d/i, value);\n      }\n\n      var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||\n        seconds < 90 && substitute($l.minute, 1) ||\n        minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||\n        minutes < 90 && substitute($l.hour, 1) ||\n        hours < 24 && substitute($l.hours, Math.round(hours)) ||\n        hours < 42 && substitute($l.day, 1) ||\n        days < 30 && substitute($l.days, Math.round(days)) ||\n        days < 45 && substitute($l.month, 1) ||\n        days < 365 && substitute($l.months, Math.round(days / 30)) ||\n        years < 1.5 && substitute($l.year, 1) ||\n        substitute($l.years, Math.round(years));\n\n      var separator = $l.wordSeparator || \"\";\n      if ($l.wordSeparator === undefined) { separator = \" \"; }\n      return $.trim([prefix, words, suffix].join(separator));\n    },\n\n    parse: function(iso8601) {\n      var s = $.trim(iso8601);\n      s = s.replace(/\\.\\d+/,\"\"); // remove milliseconds\n      s = s.replace(/-/,\"/\").replace(/-/,\"/\");\n      s = s.replace(/T/,\" \").replace(/Z/,\" UTC\");\n      s = s.replace(/([\\+\\-]\\d\\d)\\:?(\\d\\d)/,\" $1$2\"); // -04:00 -> -0400\n      s = s.replace(/([\\+\\-]\\d\\d)$/,\" $100\"); // +09 -> +0900\n      return new Date(s);\n    },\n    datetime: function(elem) {\n      var iso8601 = $t.isTime(elem) ? $(elem).attr(\"datetime\") : $(elem).attr(\"title\");\n      return $t.parse(iso8601);\n    },\n    isTime: function(elem) {\n      // jQuery's `is()` doesn't play well with HTML5 in IE\n      return $(elem).get(0).tagName.toLowerCase() === \"time\"; // $(elem).is(\"time\");\n    }\n  });\n\n  // functions that can be called via $(el).timeago('action')\n  // init is default when no action is given\n  // functions are called with context of a single element\n  var functions = {\n    init: function() {\n      var refresh_el = $.proxy(refresh, this);\n      refresh_el();\n      var $s = $t.settings;\n      if ($s.refreshMillis > 0) {\n        this._timeagoInterval = setInterval(refresh_el, $s.refreshMillis);\n      }\n    },\n    update: function(timestamp) {\n      var date = (timestamp instanceof Date) ? timestamp : $t.parse(timestamp);\n      $(this).data('timeago', { datetime: date });\n      if ($t.settings.localeTitle) $(this).attr(\"title\", date.toLocaleString());\n      refresh.apply(this);\n    },\n    updateFromDOM: function() {\n      $(this).data('timeago', { datetime: $t.parse( $t.isTime(this) ? $(this).attr(\"datetime\") : $(this).attr(\"title\") ) });\n      refresh.apply(this);\n    },\n    dispose: function () {\n      if (this._timeagoInterval) {\n        window.clearInterval(this._timeagoInterval);\n        this._timeagoInterval = null;\n      }\n    }\n  };\n\n  $.fn.timeago = function(action, options) {\n    var fn = action ? functions[action] : functions.init;\n    if (!fn) {\n      throw new Error(\"Unknown function name '\"+ action +\"' for timeago\");\n    }\n    // each over objects here and call the requested function\n    this.each(function() {\n      fn.call(this, options);\n    });\n    return this;\n  };\n\n  function refresh() {\n    var $s = $t.settings;\n\n    //check if it's still visible\n    if ($s.autoDispose && !$.contains(document.documentElement,this)) {\n      //stop if it has been removed\n      $(this).timeago(\"dispose\");\n      return this;\n    }\n\n    var data = prepareData(this);\n\n    if (!isNaN(data.datetime)) {\n      if ( $s.cutoff == 0 || Math.abs(distance(data.datetime)) < $s.cutoff) {\n        $(this).text(inWords(data.datetime));\n      }\n    }\n    return this;\n  }\n\n  function prepareData(element) {\n    element = $(element);\n    if (!element.data(\"timeago\")) {\n      element.data(\"timeago\", { datetime: $t.datetime(element) });\n      var text = $.trim(element.text());\n      if ($t.settings.localeTitle) {\n        element.attr(\"title\", element.data('timeago').datetime.toLocaleString());\n      } else if (text.length > 0 && !($t.isTime(element) && element.attr(\"title\"))) {\n        element.attr(\"title\", text);\n      }\n    }\n    return element.data(\"timeago\");\n  }\n\n  function inWords(date) {\n    return $t.inWords(distance(date));\n  }\n\n  function distance(date) {\n    return (new Date().getTime() - date.getTime());\n  }\n\n  // fix for IE6 suckage\n  document.createElement(\"abbr\");\n  document.createElement(\"time\");\n}));\n"
  },
  {
    "path": "js/src/admin.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n// jQuery Tiny Pub/Sub\n// https://github.com/cowboy/jquery-tiny-pubsub\n(function ($) {\n\tvar o = $({});\n\t$.subscribe = function () {\n\t\to.on.apply(o, arguments);\n\t};\n\n\t$.unsubscribe = function () {\n\t\to.off.apply(o, arguments);\n\t};\n\n\t$.publish = function () {\n\t\to.trigger.apply(o, arguments);\n\t};\n}(jQuery));\n\n\njQuery.ajaxSetup({\n    beforeSend: function (xhr, settings) {\n        if (settings.url.includes(\"wp-json\")) {\n            xhr.setRequestHeader(\"X-WP-Nonce\", podlove_admin_global.nonce);\n        }\n    },\n});\n\nPODLOVE.rtrim = function (string, thechar) {\n\tvar re = new RegExp(thechar + \"+$\", \"g\");\n\treturn string.replace(re, '');\n}\n\nPODLOVE.untrailingslashit = function (url) {\n\treturn PODLOVE.rtrim(url, '/');\n}\n\nPODLOVE.trailingslashit = function (url) {\n\treturn PODLOVE.untrailingslashit(url) + '/';\n}\n\nfunction convert_to_slug(string) {\n\tstring = string.toLowerCase();\n\tstring = string.replace(/\\s+/g, '-');\n\tstring = string.replace(/[\\u00e4]/g, 'ae');\n\tstring = string.replace(/[\\u00f6]/g, 'oe');\n\tstring = string.replace(/[\\u00fc]/g, 'ue');\n\tstring = string.replace(/[\\u00df]/g, 'ss');\n\tstring = string.replace(/[^\\w\\-]+/g, '');\n\tstring = escape(string);\n\treturn string;\n}\n\nfunction auto_fill_form(id, title_id) {\n\t(function ($) {\n\t\tswitch (id) {\n\t\t\tcase 'contributor':\n\t\t\t\tif ($(\"#podlove_contributor_publicname\").val() == \"\") {\n\t\t\t\t\tif ($(\"#podlove_contributor_realname\").val() == \"\") {\n\t\t\t\t\t\t$(\"#podlove_contributor_publicname\").attr('placeholder', $(\"#podlove_contributor_nickname\").val());\n\t\t\t\t\t} else {\n\t\t\t\t\t\t$(\"#podlove_contributor_publicname\").attr('placeholder', $(\"#podlove_contributor_realname\").val());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase 'contributor_group':\n\t\t\t\tif ($(\"#podlove_contributor_group_slug\").val() == \"\") {\n\t\t\t\t\t$(\"#podlove_contributor_group_slug\").val(convert_to_slug($(\"#podlove_contributor_\" + title_id).val()));\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase 'contributor_role':\n\t\t\t\tif ($(\"#podlove_contributor_role_slug\").val() == \"\") {\n\t\t\t\t\t$(\"#podlove_contributor_role_slug\").val(convert_to_slug($(\"#podlove_contributor_\" + title_id).val()));\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t}\n\n\n\t}(jQuery));\n}\n\n/**\n * HTML-based input behavior for text fields.\n *\n * To activate behavior, add class `podlove-check-input`.\n *\n * - trims whitespace from beginning and end\n *\n * Add these data attributes to add further behavior:\n *\n * - `data-podlove-input-type=\"url\"`   : verifies against URL regex\n * - `data-podlove-input-type=\"avatar\"`: verifies against URL or email regex\n * - `data-podlove-input-type=\"email\"` : verifies against email regex\n * - `data-podlove-input-remove=\"@ +\"` : removes given whitespace separated list of characters from input\n *\n * Expects HTML to be in the following form:\n *\n * ```html\n * <input type=\"text\" id=\"inputid\" class=\"podlove-check-input\">\n * <span class=\"podlove-input-status\" data-podlove-input-status-for=\"inputid\"></span>\n * ```\n */\nfunction clean_up_input() {\n\t(function ($) {\n\t\t$(\".podlove-check-input\").on('change', function () {\n\t\t\tvar textfield = $(this);\n\t\t\tvar textfieldid = textfield.attr(\"id\");\n\t\t\tvar $status = $(\".podlove-input-status[data-podlove-input-status-for=\" + textfieldid + \"]\");\n\n\t\t\ttextfield.removeClass(\"podlove-invalid-input\");\n\t\t\t$status.removeClass(\"podlove-input-isinvalid\");\n\n\t\t\tfunction ShowInputError(message) {\n\t\t\t\t$status.text(message);\n\n\t\t\t\ttextfield.addClass(\"podlove-invalid-input\");\n\t\t\t\t$status.addClass(\"podlove-input-isinvalid\");\n\t\t\t}\n\n\t\t\t// trim whitespace\n\t\t\ttextfield.val(textfield.val().trim());\n\n\t\t\t// remove blacklisted characters\n\t\t\tif (inputType = $(this).data(\"podlove-input-remove\")) {\n\t\t\t\tcharacters = $(this).data(\"podlove-input-remove\").split(' ');\n\t\t\t\t$.each(characters, function (index, character) {\n\t\t\t\t\ttextfield.val(textfield.val().replace(character, ''));\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// handle special input types\n\t\t\tif (inputType = $(this).data(\"podlove-input-type\")) {\n\t\t\t\t$status.text('');\n\n\t\t\t\tif ($(this).val() == '')\n\t\t\t\t\treturn;\n\n\t\t\t\tswitch (inputType) {\n\t\t\t\t\tcase \"url\":\n\t\t\t\t\t\tvalid_url_regexp = /^(?:(?:https?|ftp):\\/\\/)(?:\\S+(?::\\S*)?@)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:\\/[^\\s]*)?$/i;\n\n\t\t\t\t\t\tif (!textfield.val().match(valid_url_regexp)) {\n\t\t\t\t\t\t\t// Encode URL only if it is not already encoded\n\t\t\t\t\t\t\tif (!encodeURI(textfield.val()).match(valid_url_regexp)) {\n\t\t\t\t\t\t\t\tShowInputError('Please enter a valid URL');\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttextfield.val(encodeURI(textfield.val()));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"avatar\":\n\t\t\t\t\t\tif (!textfield.val().match(/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$/i)) {\n\t\t\t\t\t\t\t// textfield.val( encodeURI( textfield.val() ) );\n\n\t\t\t\t\t\t\tif (!textfield.val().match(/^(?:(?:https?|ftp):\\/\\/)(?:\\S+(?::\\S*)?@)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:\\/[^\\s]*)?$/i)) {\n\t\t\t\t\t\t\t\tShowInputError('Please enter a valid email adress or a valid URL');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"email\":\n\t\t\t\t\t\tif (!textfield.val().match(/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$/i))\n\t\t\t\t\t\t\tShowInputError('Please enter a valid email adress.');\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}(jQuery));\n}\n\n/**\n * Initialize contextual help links.\n *\n *\tUse like this:\n *\n *  <a href=\"#\" data-podlove-help=\"help-tab-id\">?</a>\n */\nfunction init_contextual_help_links() {\n\tjQuery(\"a[data-podlove-help]\").on(\"click\", function (e) {\n\t\tvar help_id = jQuery(this).data('podlove-help');\n\n\t\te.preventDefault();\n\n\t\t// Remove 'active' class from all link tabs\n\t\tjQuery('li[id^=\"tab-link-\"]').each(function () {\n\t\t\tjQuery(this).removeClass('active');\n\t\t});\n\n\t\t// Hide all panels\n\t\tjQuery('div[id^=\"tab-panel-\"]').each(function () {\n\t\t\tjQuery(this).css('display', 'none');\n\t\t});\n\n\t\t// Set our desired link/panel\n\t\tjQuery('#tab-link-' + help_id).addClass('active');\n\t\tjQuery('#tab-panel-' + help_id).css('display', 'block');\n\n\t\t// Force click on the Help tab\n\t\tif (jQuery('#contextual-help-link').attr('aria-expanded') === \"false\") {\n\t\t\tjQuery('#contextual-help-link').click();\n\t\t}\n\n\t\t// Force scroll to top, so you can actually see the help\n\t\twindow.scroll(0, 0);\n\t});\n}\n\njQuery(function ($) {\n\n\t$(\"#_podlove_meta_recording_date\").datepicker({\n\t\tdateFormat: 'yy-mm-dd'\n\t});\n\n\t$(\"#dashboard_feed_info\").each(function () {\n\t\tPODLOVE.DashboardFeedValidation($(this));\n\t});\n\n\t$(\"#asset_validation\").each(function () {\n\t\tPODLOVE.DashboardAssetValidation($(this));\n\t});\n\n\t$(\"#podlove_podcast\").each(function () {\n\t\tPODLOVE.Episode($(this));\n\t});\n\n\t$(\"#podlove_episode_assets, table.episode_assets\").each(function () {\n\t\tPODLOVE.EpisodeAssetSettings($(this));\n\t});\n\n\t$(\".wrap\").each(function () {\n\t\tPODLOVE.FeedSettings($(this));\n\t});\n\n\t$(\".row_podlove_feed_protected\").each(function () {\n\t\tPODLOVE.ProtectFeed();\n\t});\n\n\t$(\"#podlove_contributor_publicname\").change(function () {\n\t\tauto_fill_form('contributor', 'realname');\n\t});\n\n\t$(\"#podlove_contributor_realname\").change(function () {\n\t\tauto_fill_form('contributor', 'realname');\n\t});\n\n\t$(\"#podlove_contributor_nickname\").change(function () {\n\t\tauto_fill_form('contributor', 'realname');\n\t});\n\n\t$(\"#podlove_contributor_group_title\").change(function () {\n\t\tauto_fill_form('contributor_group', 'group_title');\n\t});\n\n\t$(\"#podlove_contributor_role_title\").change(function () {\n\t\tauto_fill_form('contributor_role', 'role_title');\n\t});\n\n\t$(document).ready(function () {\n\t\tauto_fill_form('contributor', 'realname');\n\t\tclean_up_input();\n\t\tinit_contextual_help_links();\n\t\tnew ClipboardJS('.clipboard-btn');\n\t});\n\n});\n"
  },
  {
    "path": "js/src/analytics/common.js",
    "content": "var PODLOVE = PODLOVE || {};\nPODLOVE.Analytics = PODLOVE.Analytics || {};\n\ndc.config.defaultColors(d3.schemeAccent);\n\n/**\n * round to <digits> digits after comma.\n *\n * decimalRound(5.123,1) // => 5.1\n * decimalRound(5.678,2) // => 5.68\n */\nPODLOVE.Analytics.decimalRound = function (number, digits) {\n\tvar exp = Math.pow(10, digits);\n\n\tnumber *= exp;\n\tnumber = Math.round(number);\n\tnumber /= exp;\n\n\treturn number;\n};\n\nPODLOVE.Analytics.formatThousands = function (num) {\n\tif (num < 1000)\n\t\treturn num;\n\telse\n\t\treturn PODLOVE.Analytics.decimalRound(num / 1000, 1) + \"k\";\n};\n\nPODLOVE.Analytics.hourFormat = function (hours) {\n\tvar days = 0,\n\t\tweeks = 0,\n\t\tlabel = [];\n\n\tif (hours > 48) {\n\t\tdays = (hours - hours % 24) / 24;\n\t\thours = hours % 24;\n\t}\n\n\tif (days > 13) {\n\t\tweeks = (days - days % 7) / 7;\n\t\tdays = days % 7;\n\t};\n\n\tif (weeks)\n\t\tlabel.push(PODLOVE.Analytics.decimalRound(weeks, 1) + \"w\");\n\n\tif (days)\n\t\tlabel.push(PODLOVE.Analytics.decimalRound(days, 1) + \"d\");\n\n\tif (hours)\n\t\tlabel.push(PODLOVE.Analytics.decimalRound(hours, 1) + \"h\")\n\n\tif (label.length === 0)\n\t\tlabel = [\"0h\"];\n\n\treturn label.join(\" \");\n};\n\nPODLOVE.Analytics.addPercentageLabels = function (chart, total) {\n\tvar data = chart.data();\n\tvar filters = chart.filters();\n\n\tdata.forEach(function (d, index) {\n\t\tvar row = chart.select('g.row._' + index);\n\t\tvar label = chart.select('g.row._' + index + ' text');\n\t\tvar text = '';\n\n\t\tif (!row.select('.subLabel').size()) {\n\t\t\trow.append('text')\n\t\t\t\t.attr('class', 'subLabel')\n\t\t\t\t.attr('text-anchor', 'end')\n\t\t\t\t.attr('x', -10)\n\t\t\t\t.attr('y', label.attr('y'));\n\t\t}\n\n\t\t// when a filter is set, only show active rows\n\t\tif (filters.length > 0 && filters.includes(d.key)) {\n\t\t\trow.select('.subLabel').style({\n\t\t\t\t'display': 'none'\n\t\t\t});\n\t\t} else {\n\t\t\trow.select('.subLabel').style({\n\t\t\t\t'display': 'inherit'\n\t\t\t});\n\t\t};\n\n\t\tif (total > 0) {\n\t\t\ttext = Math.round(d.value / total * 100) + '%';\n\t\t}\n\n\t\trow.select('.subLabel').text(text);\n\t});\n};\n"
  },
  {
    "path": "js/src/analytics/episode.js",
    "content": "/* global PODLOVE, assetNames, d3, dc, jQuery, crossfilter, ajaxurl */\n\n'use strict';\n\nNumber.isNaN = Number.isNaN || function (value) {\n\treturn value !== value;\n}\n\njQuery(document).ready(function ($) {\n\n\tvar csvCurEpisodeRawData, csvAvgEpisodeRawData;\n\n\tvar titleDateFormat = d3.timeFormat('%Y-%m-%d %H:%M %Z');\n\n\tvar episode_id = jQuery('#episode-performance-chart').data('episode');\n\tvar chart_width = $('#episode-performance-chart').closest('.inside').width();\n\tvar brush = {\n\t\tmin: null,\n\t\tmax: null\n\t};\n\n\tlet enableAvgEpisodeChart, disableAvgEpisodeChart;\n\n\tvar reduceAddFun = function (p, v) {\n\n\t\tp.downloads += v.downloads;\n\n\t\tp.asset_id = v.asset_id;\n\t\tp.date = p.date && p.date < v.date ? p.date : v.date; // take first date in reduced set\n\t\tp.client = v.client;\n\t\tp.system = v.system;\n\t\tp.source = v.source;\n\t\tp.context = v.context;\n\t\tp.geo = v.geo;\n\n\t\treturn p;\n\t};\n\tvar reduceSubFun = function (p, v) {\n\t\tp.downloads -= v.downloads;\n\t\treturn p;\n\t};\n\tvar reduceBaseFun = function () {\n\t\treturn {\n\t\t\tdownloads: 0,\n\t\t\tasset_id: 0,\n\t\t\tdate: 0,\n\t\t\tclient: '',\n\t\t\tsystem: '',\n\t\t\tgeo: ''\n\t\t};\n\t};\n\n\tfunction render_episode_performance_chart(options) {\n\t\tvar hours_per_unit = options.hours_per_unit;\n\n\t\tvar xfilter = crossfilter(csvCurEpisodeRawData);\n\t\tvar xfilterAvg = crossfilter(csvAvgEpisodeRawData);\n\t\tvar all = xfilter.groupAll().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\tvar addPercentageLabels = function (chart) {\n\t\t\tvar data = chart.data();\n\t\t\tvar filters = chart.filters();\n\n\t\t\tdata.forEach(function (d, index) {\n\t\t\t\tvar row = chart.select('g.row._' + index);\n\t\t\t\tvar label = chart.select('g.row._' + index + ' text');\n\t\t\t\tvar text = '';\n\n\t\t\t\tif (!row.select('.subLabel').size()) {\n\t\t\t\t\trow.append('text')\n\t\t\t\t\t\t.attr('class', 'subLabel')\n\t\t\t\t\t\t.attr('text-anchor', 'end')\n\t\t\t\t\t\t.attr('x', -10)\n\t\t\t\t\t\t.attr('y', label.attr('y'));\n\t\t\t\t}\n\n\t\t\t\t// when a filter is set, only show active rows\n\t\t\t\tif (filters.length > 0 && filters.includes(d.key)) {\n\t\t\t\t\trow.select('.subLabel').style({\n\t\t\t\t\t\t'display': 'none'\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\trow.select('.subLabel').style({\n\t\t\t\t\t\t'display': 'inherit'\n\t\t\t\t\t});\n\t\t\t\t};\n\n\t\t\t\tif (all.value().downloads > 0) {\n\t\t\t\t\ttext = Math.round(d.value.downloads / all.value().downloads * 100) + '%';\n\t\t\t\t}\n\n\t\t\t\trow.select('.subLabel').text(text);\n\t\t\t});\n\t\t};\n\n\t\tvar addResetFilter = function (chart, filter) {\n\t\t\tchart.select(\".reset\").on(\"click\", function (data, index) {\n\t\t\t\td3.event.preventDefault();\n\t\t\t\tchart.filterAll();\n\t\t\t\tdc.redrawAll();\n\t\t\t});\n\t\t};\n\n\t\t/**\n\t\t * Dimensions & Groups\n\t\t */\n\t\tvar dimRelativeHoursSinceRelease = function (d) {\n\t\t\treturn Math.floor(d.hoursSinceRelease / hours_per_unit);\n\t\t};\n\n\t\t// dimension: \"hours since release\"\n\t\tvar hoursDimension = xfilter.dimension(dimRelativeHoursSinceRelease);\n\n\t\t// dimension: \"hours since release\"\n\t\tvar avgEpisodeHoursDimension = xfilterAvg.dimension(dimRelativeHoursSinceRelease);\n\n\t\t// dimension: asset id\n\t\tvar assetDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.asset_id;\n\t\t});\n\n\t\t// dimension: client\n\t\tvar clientDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.client;\n\t\t});\n\n\t\t// dimension: operating system\n\t\tvar systemDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.system;\n\t\t});\n\n\t\t// dimension: download source\n\t\tvar sourceDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.source;\n\t\t});\n\n\t\t// dimension: download context\n\t\tvar contextDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.context;\n\t\t});\n\n\t\tvar geoDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.geo;\n\t\t})\n\n\t\t// group: downloads\n\t\tvar downloadsGroup = hoursDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\t// group: downloads\n\t\tvar avgDownloadsGroup = avgEpisodeHoursDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\t// group: cumulative downloads\n\t\tvar _cumulativeDownloadsGroup = hoursDimension.group()\n\t\t\t.reduce(reduceAddFun, reduceSubFun, reduceBaseFun)\n\t\t\t.all()\n\t\t\t.reduce(function (acc, cur) {\n\t\t\t\tcur.key += 1; // shift all keys to make space for a zero-entry\n\t\t\t\tcur.value.cum = true; // set flag to identify cumulative data set\n\t\t\t\tif (acc.length) {\n\t\t\t\t\tcur.value.downloads += acc.slice(-1)[0].value.downloads;\n\t\t\t\t}\n\t\t\t\tacc.push(cur);\n\t\t\t\treturn acc;\n\t\t\t}, []);\n\n\t\t// add zero-entry\n\t\t_cumulativeDownloadsGroup.unshift({\n\t\t\tkey: 0,\n\t\t\tvalue: {\n\t\t\t\tdate: 0,\n\t\t\t\tdownloads: 0,\n\t\t\t\tcum: true\n\t\t\t}\n\t\t});\n\n\t\tvar cumulativeDownloadsGroup = {\n\t\t\tall: function () {\n\t\t\t\treturn _cumulativeDownloadsGroup;\n\t\t\t}\n\t\t};\n\n\t\t// group: downloads per asset\n\t\tvar assetsGroup = assetDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\t// group: downloads per client\n\t\tvar clientsGroup = clientDimension.group()\n\t\t\t.reduce(reduceAddFun, reduceSubFun, reduceBaseFun)\n\t\t\t.order(function (v) {\n\t\t\t\treturn v.downloads;\n\t\t\t});\n\n\t\t// group: downloads per operating system\n\t\tvar systemsGroup = systemDimension.group()\n\t\t\t.reduce(reduceAddFun, reduceSubFun, reduceBaseFun)\n\t\t\t.order(function (v) {\n\t\t\t\treturn v.downloads;\n\t\t\t});\n\n\t\t// group: downloads by source\n\t\tvar sourceGroup = sourceDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\t// group: downloads by context\n\t\tvar contextGroup = contextDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\tvar geoGroup = geoDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\t/**\n\t\t * Charts\n\t\t */\n\t\tvar chartColor = '#69B3FF';\n\t\tvar compChart = dc.compositeChart('#episode-performance-chart')\n\n\t\tvar downloadsChart = dc.barChart(compChart)\n\t\t\t.dimension(hoursDimension)\n\t\t\t.group(downloadsGroup, 'Current Episode')\n\t\t\t.renderTitle(true)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.gap(1)\n\t\t\t.colors(chartColor);\n\n\t\tvar avgEpisodeDownloadsChart = dc.barChart(compChart)\n\t\t\t.dimension(hoursDimension)\n\t\t\t.group(avgDownloadsGroup, 'Average Episode')\n\t\t\t.renderTitle(true)\n\t\t\t.colors('#224BA6')\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.barPadding(2);\n\n\t\tvar cumulativeEpisodeChart = dc.lineChart(compChart)\n\t\t\t.dimension(hoursDimension)\n\t\t\t.group(cumulativeDownloadsGroup, 'Cumulative')\n\t\t\t.colors('#CCC')\n\t\t\t.useRightYAxis(true)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.renderDataPoints({})\n\t\t\t.renderArea(true);\n\n\t\tvar rangeChartXAxisLength = downloadsGroup.all().reduce(function (prev, cur) {\n\t\t\treturn Math.max(prev, cur.key);\n\t\t}, 0);\n\n\t\tvar rangeChart = dc.barChart('#episode-range-chart')\n\t\t\t.width(chart_width)\n\t\t\t.height(80)\n\t\t\t.dimension(hoursDimension)\n\t\t\t.group(downloadsGroup)\n\t\t\t.x(d3.scaleLinear().domain([0, rangeChartXAxisLength]))\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.yAxisLabel(' ') // to align yaxis with main chart\n\t\t;\n\n\t\twindow.rangeChart = rangeChart;\n\n\t\tcompChart\n\t\t\t.width(chart_width)\n\t\t\t.x(d3.scaleLinear().domain([0, Infinity]))\n\t\t\t.legend(dc.legend().x(chart_width - 160).y(20).itemHeight(13).gap(5))\n\t\t\t.elasticX(false)\n\t\t\t// .elasticY(true)\n\t\t\t.brushOn(false)\n\t\t\t.transitionDuration(0) // turn off transitions\n\t\t\t.yAxisLabel('Downloads')\n\t\t\t.xAxisLabel('Hours since release')\n\t\t\t.rangeChart(rangeChart)\n\t\t\t.title(function (d) {\n\n\t\t\t\tvar title = d.value.date ? titleDateFormat(d.value.date) : 'Average Episode',\n\t\t\t\t\ttime = '';\n\n\t\t\t\tif (d.value.cum) {\n\t\t\t\t\ttime = (d.key * hours_per_unit) + 'h after release';\n\t\t\t\t} else {\n\t\t\t\t\ttime = (d.key * hours_per_unit) + 'h – ' + ((d.key + 1) * hours_per_unit) + 'h after release';\n\t\t\t\t}\n\n\t\t\t\treturn [\n\t\t\t\t\ttitle,\n\t\t\t\t\ttime,\n\t\t\t\t\t'Downloads: ' + d.value.downloads\n\t\t\t\t].join('\\n');\n\t\t\t})\n\t\t\t.compose([cumulativeEpisodeChart, downloadsChart, avgEpisodeDownloadsChart])\n\t\t\t.rightYAxisLabel('Cumulative Downloads');\n\n\t\tvar assetChart = dc.rowChart('#episode-asset-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(assetDimension) // set dimension\n\t\t\t.group(assetsGroup) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value.downloads;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.label(function (d) {\n\t\t\t\treturn assetNames[d.key];\n\t\t\t})\n\t\t\t.title(function (d) {\n\t\t\t\treturn d.value.downloads;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);;\n\n\t\tvar clientChart = dc.rowChart('#episode-client-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(clientDimension)\n\t\t\t.group(clientsGroup)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.cap(10)\n\t\t\t.label(function (d) {\n\t\t\t\treturn d.key;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);;\n\n\t\tvar systemChart = dc.rowChart('#episode-system-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(systemDimension)\n\t\t\t.group(systemsGroup)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.cap(10)\n\t\t\t.label(function (d) {\n\t\t\t\treturn d.key;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);;\n\n\t\tvar sourceChart = dc.rowChart('#episode-source-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(sourceDimension)\n\t\t\t.group(sourceGroup)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.label(function (d) {\n\t\t\t\treturn d.key;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);;\n\n\t\tvar contextChart = dc.rowChart('#episode-context-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(contextDimension)\n\t\t\t.group(contextGroup)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.label(function (d) {\n\t\t\t\treturn d.value.source + '/' + d.key;\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);\n\n\t\tvar geoChart = dc.rowChart('#episode-geo-chart')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.cap(10)\n\t\t\t.dimension(geoDimension)\n\t\t\t.group(geoGroup)\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.ordering(function (v) {\n\t\t\t\treturn -v.value.downloads;\n\t\t\t})\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('preRedraw', addPercentageLabels)\n\t\t\t.on('renderlet', addResetFilter);\n\n\t\t// set tickFormats for all charts\n\t\trangeChart.yAxis().ticks([2]);\n\t\trangeChart.xAxis().tickFormat(function (v) {\n\t\t\treturn PODLOVE.Analytics.hourFormat(v * hours_per_unit);\n\t\t});\n\n\t\tcompChart.xAxis().tickFormat(function (v) {\n\t\t\treturn PODLOVE.Analytics.hourFormat(v * hours_per_unit);\n\t\t});\n\n\t\trangeChart.yAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tcompChart.yAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tcompChart.rightYAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tassetChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tclientChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tsystemChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tsourceChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tcontextChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tgeoChart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\n\t\tenableAvgEpisodeChart = function () {\n\t\t\tcompChart.elasticY(true).compose([cumulativeEpisodeChart, downloadsChart, avgEpisodeDownloadsChart])\n\t\t\tcompChart.render()\n\t\t};\n\n\t\tdisableAvgEpisodeChart = function () {\n\t\t\tcompChart.elasticY(true).compose([cumulativeEpisodeChart, downloadsChart])\n\t\t\tcompChart.render()\n\t\t};\n\n\t\t[compChart, rangeChart, assetChart, clientChart, systemChart, sourceChart, contextChart, geoChart].forEach(function (chart) {\n\t\t\tchart.render();\n\t\t});\n\n\t\tvar filterHours = function (min, max) {\n\t\t\tdc.filterAll();\n\t\t\trangeChart.filter(dc.filters.RangedFilter(min / hours_per_unit, max / hours_per_unit))\n\t\t\tdc.redrawAll();\n\t\t}\n\n\t\twindow.filterHours = filterHours;\n\n\t\t// set range from 0 to 'one week' or 'everything' if the episode is younger than a week\n\t\tif (!brush.min && !brush.max) {\n\t\t\tbrush.min = 0;\n\t\t\tbrush.max = 7 * 24;\n\t\t\t$('#chart-zoom-selection .button:eq(1)').addClass('active');\n\t\t}\n\n\t\t$('#chart-zoom-selection .button').on('click', function (e) {\n\t\t\tvar hours = parseInt($(this).data('hours'), 10);\n\n\t\t\te.preventDefault();\n\n\t\t\tif ($(this).hasClass(\"disabled\")) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t$(this).siblings().removeClass('active');\n\t\t\t$(this).addClass('active');\n\n\t\t\tif (hours === 0) {\n\t\t\t\t// set to full range\n\t\t\t\tbrush.min = 0;\n\t\t\t\tbrush.max = rangeChart.xUnitCount() * hours_per_unit;\n\t\t\t} else {\n\t\t\t\t// extend to set range\n\t\t\t\tbrush.max = brush.min + hours;\n\t\t\t}\n\n\t\t\tfilterHours(brush.min, brush.max)\n\t\t});\n\n\t\tif (options.rendered && options.rendered instanceof Function) {\n\t\t\toptions.rendered();\n\t\t}\n\t}\n\n\tfunction load_episode_performance_chart(options) {\n\n\t\tif (csvCurEpisodeRawData) {\n\t\t\trender_episode_performance_chart(options);\n\t\t} else {\n\t\t\t$.when(\n\t\t\t\t$.ajax(ajaxurl + '?action=podlove-analytics-episode-downloads-per-hour&episode=' + episode_id),\n\t\t\t\t$.ajax(ajaxurl + '?action=podlove-analytics-episode-average-downloads-per-hour')\n\t\t\t).done(function (csvCurEpisode, csvAvgEpisode) {\n\n\t\t\t\tvar csvMapper = function (d) {\n\t\t\t\t\tvar parsed_date = new Date(+d.date * 1000);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdate: parsed_date,\n\t\t\t\t\t\tdownloads: +d.downloads,\n\t\t\t\t\t\thoursSinceRelease: +d.hours_since_release,\n\t\t\t\t\t\tasset_id: +d.asset_id,\n\t\t\t\t\t\tclient: d.client ? d.client : 'Unknown',\n\t\t\t\t\t\tsystem: d.system ? d.system : 'Unknown',\n\t\t\t\t\t\tsource: d.source ? d.source : 'Unknown',\n\t\t\t\t\t\tcontext: d.context ? d.context : 'Unknown',\n\t\t\t\t\t\tgeo: d.geo ? d.geo : 'Unknown'\n\t\t\t\t\t};\n\t\t\t\t};\n\n\t\t\t\tcsvCurEpisodeRawData = d3.csvParse(csvCurEpisode[0], csvMapper);\n\t\t\t\tcsvAvgEpisodeRawData = d3.csvParse(csvAvgEpisode[0], function (d) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\thoursSinceRelease: +d.hoursSinceRelease,\n\t\t\t\t\t\tdownloads: +d.downloads\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t\trender_episode_performance_chart(options);\n\t\t\t});\n\t\t}\n\t}\n\n\t$('#chart-grouping-selection').on('click', 'a', function (e) {\n\t\tvar unit_hours = parseInt($(this).data('hours'), 10),\n\t\t\tzoom_hours = parseInt($('#chart-zoom-selection .button.active').data(\"hours\"), 10);\n\n\t\t$(this).siblings().removeClass('active');\n\t\t$(this).addClass('active');\n\n\t\t// deactivate all zoom buttons smaller than unit selection\n\t\t$(\"#chart-zoom-selection a.button\").each(function () {\n\t\t\tvar h = parseInt($(this).data('hours'), 10);\n\n\t\t\tif (h !== 0 && h < unit_hours) {\n\t\t\t\t$(this).addClass(\"disabled\");\n\t\t\t} else {\n\t\t\t\t$(this).removeClass(\"disabled\");\n\t\t\t}\n\t\t});\n\n\t\tload_episode_performance_chart({\n\t\t\thours_per_unit: unit_hours,\n\t\t\trendered: function () {\n\t\t\t\t// check if zoom setting makes sense\n\t\t\t\tif (zoom_hours !== 0 && (Number.isNaN(zoom_hours) || zoom_hours < unit_hours)) {\n\t\t\t\t\t$(\"#chart-zoom-selection a.button\").filter(function () {\n\t\t\t\t\t\tvar h = parseInt($(this).data('hours'), 10);\n\t\t\t\t\t\treturn h === 0 || h >= unit_hours;\n\t\t\t\t\t}).first().click();\n\t\t\t\t} else {\n\t\t\t\t\t$(\"#chart-zoom-selection a.button.active:first\").click();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\te.preventDefault();\n\t});\n\n\t$('#chart-grouping-selection a:eq(3)').click();\n\n\t/**\n\t * Analytics Tiles can be hidden via Screen Options\n\t */\n\t$('input[name=\\'podlove_analytics_tiles\\']').each(function () {\n\t\tvar checked = $(this).attr('checked'),\n\t\t\ttile_id = $(this).val(),\n\t\t\tchart = $('.chart-wrapper[data-tile-id=\\'' + tile_id + '\\']');\n\n\t\tif (!checked) {\n\t\t\tchart.hide();\n\t\t}\n\t}).on('click', function () {\n\t\tvar checked = $(this).attr('checked'),\n\t\t\ttile_id = $(this).val(),\n\t\t\tchart = $('.chart-wrapper[data-tile-id=\\'' + tile_id + '\\']');\n\n\t\t// save\n\t\t$.ajax({\n\t\t\turl: ajaxurl,\n\t\t\tdata: {\n\t\t\t\taction: 'podlove-analytics-settings-tiles-update',\n\t\t\t\ttile_id: tile_id,\n\t\t\t\tchecked: checked\n\t\t\t}\n\t\t});\n\n\t\t// update UI\n\t\tif (checked) {\n\t\t\tchart.show();\n\t\t} else {\n\t\t\tchart.hide();\n\t\t}\n\t});\n\n\tconst setAverageEpisodeSetting = function () {\n\t\tif (typeof disableAvgEpisodeChart == \"undefined\") {\n\t\t\twindow.setTimeout(setAverageEpisodeSetting, 500);\n\t\t} else {\n\t\t\tconst checked = $('#average-episode').attr('checked')\n\n\t\t\tif (checked) {\n\t\t\t\tenableAvgEpisodeChart()\n\t\t\t} else {\n\t\t\t\tdisableAvgEpisodeChart()\n\t\t\t}\n\t\t}\n\t}\n\n\tconst saveAverageEpisodeSetting = function () {\n\t\t$.ajax({\n\t\t\turl: ajaxurl,\n\t\t\tdata: {\n\t\t\t\taction: 'podlove-analytics-settings-avg-update',\n\t\t\t\tchecked: $(this).attr('checked')\n\t\t\t}\n\t\t});\n\t}\n\n\t$('#average-episode')\n\t\t.each(setAverageEpisodeSetting)\n\t\t.on('click', function () {\n\t\t\tsaveAverageEpisodeSetting();\n\t\t\tsetAverageEpisodeSetting();\n\t\t})\n\n});\n"
  },
  {
    "path": "js/src/analytics/totals.js",
    "content": "jQuery(document).ready(function ($) {\n\tvar totalsRawData;\n\tvar aboTotalsRawData;\n\n\tvar reduceAddFun = function (p, v) {\n\n\t\tp.downloads += v.downloads;\n\n\t\tp.episode_id = v.episode_id;\n\t\tp.date = v.date;\n\n\t\treturn p;\n\t};\n\tvar reduceSubFun = function (p, v) {\n\t\tp.downloads -= v.downloads;\n\t\treturn p;\n\t};\n\tvar reduceBaseFun = function () {\n\t\treturn {\n\t\t\tdownloads: 0,\n\t\t\tepisode_id: 0,\n\t\t\tdate: 0,\n\t\t};\n\t};\n\n\tfunction render_episode_performance_chart() {\n\t\tvar chart_container = $(\"#total-chart\");\n\t\tvar chart_width;\n\n\t\tif (chart_container.closest(\".postbox-container\").length) {\n\t\t\tchart_width = chart_container.closest(\".postbox-container\").width();\n\t\t} else {\n\t\t\tchart_width = chart_container.closest(\".wrap\").width();\n\t\t}\n\n\t\tvar xfilter = crossfilter(totalsRawData);\n\t\tvar all = xfilter.groupAll().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\t\tvar total_downloads = all.value().downloads;\n\n\t\t// dimension: \"hours since release\"\n\t\tvar dateDimension = xfilter.dimension(function (d) {\n\t\t\t// return d.date;\n\t\t\treturn d3.timeDay(d.date);\n\t\t});\n\n\t\tvar episodeDimension = xfilter.dimension(function (d) {\n\t\t\treturn d.episode_id;\n\t\t});\n\t\tvar episodeGroup = episodeDimension.group().reduce(function (p, v) {\n\t\t\tp.downloads += v.downloads;\n\t\t\tp.episode_id = v.episode_id;\n\t\t\treturn p;\n\t\t}, null, function () {\n\t\t\treturn {\n\t\t\t\tdownloads: 0,\n\t\t\t\tepisode_id: 0,\n\t\t\t};\n\t\t})\n\n\t\t// threshold to make it to top episodes: more than 5% of total downloads in time segment\n\t\tvar top_episodes = episodeGroup.all().reduce(function (acc, cur) {\n\n\t\t\tif (cur.value.downloads > total_downloads * 0.04) {\n\t\t\t\tacc.push(cur);\n\t\t\t}\n\n\t\t\treturn acc;\n\t\t}, []);\n\n\t\tvar top_episode_ids = _.pluck(top_episodes, \"key\");\n\n\t\t// group: downloads\n\t\t// var downloadsGroup = dateDimension.group().reduce(reduceAddFun, reduceSubFun, reduceBaseFun);\n\n\t\tvar downloadsWithoutTopGroup = dateDimension.group().reduce(function (p, v) {\n\t\t\tif (!_.contains(top_episode_ids, v.episode_id)) {\n\t\t\t\treturn reduceAddFun(p, v);\n\t\t\t} else {\n\t\t\t\treturn p;\n\t\t\t}\n\t\t}, function (p, v) {\n\t\t\tif (!_.contains(top_episode_ids, v.episode_id)) {\n\t\t\t\treturn reduceSubFun(p, v);\n\t\t\t} else {\n\t\t\t\treturn p;\n\t\t\t}\n\t\t}, reduceBaseFun);\n\n\t\tvar filter_dimension_by_episode_id = function (dim, episode_id) {\n\t\t\treturn dim.group().reduce(function (p, v) {\n\t\t\t\tif (v.episode_id == episode_id) {\n\t\t\t\t\treturn reduceAddFun(p, v);\n\t\t\t\t} else {\n\t\t\t\t\treturn p;\n\t\t\t\t}\n\t\t\t}, function (p, v) {\n\t\t\t\tif (v.episode_id == episode_id) {\n\t\t\t\t\treturn reduceSubFun(p, v);\n\t\t\t\t} else {\n\t\t\t\t\treturn p;\n\t\t\t\t}\n\t\t\t}, reduceBaseFun);\n\t\t}\n\n\t\tvar top_episode_groups = [];\n\n\t\tfor (var index in top_episode_ids) {\n\t\t\ttop_episode_groups[top_episode_ids[index]] = (filter_dimension_by_episode_id(dateDimension, top_episode_ids[index]));\n\t\t}\n\n\t\t/**\n\t\t * Charts\n\t\t */\n\t\tvar daysAgo = function (days) {\n\t\t\treturn new Date(new Date().setDate(new Date().getDate() - days));\n\t\t};\n\n\t\tvar titleDateFormat = d3.timeFormat(\"%Y-%m-%d\");\n\n\t\tvar downloadsChart = dc.barChart(\"#total-chart\")\n\t\t\t.width(chart_width)\n\t\t\t.height(200)\n\t\t\t.dimension(dateDimension)\n\t\t\t.group(downloadsWithoutTopGroup, \"Other Episodes\")\n\t\t\t.x(d3.scaleTime().domain([daysAgo(28), new Date()]))\n\t\t\t.xUnits(d3.timeDays)\n\t\t\t.brushOn(false)\n\t\t\t.renderTitle(true)\n\t\t\t.elasticY(true)\n\t\t\t.yAxisLabel(\"Downloads\")\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\treturn v.value.downloads;\n\t\t\t})\n\t\t\t.title(function (d) {\n\t\t\t\treturn [\n\t\t\t\t\ttitleDateFormat(d.key),\n\t\t\t\t\t\"Downloads: \" + d.value.downloads\n\t\t\t\t].join(\"\\n\");\n\t\t\t})\n\t\t\t.renderHorizontalGridLines(true);\n\n\t\t// responsive legend position\n\t\tvar legendWidth = 300;\n\t\tif (chart_width > 650) {\n\t\t\tvar legendX = chart_width - legendWidth;\n\n\t\t\tjQuery(\"#total-chart\").height(\"200px\")\n\t\t\tdownloadsChart.height(200);\n\t\t\tdownloadsChart.legend(dc.legend().horizontal(false).x(legendX).y(10).autoItemWidth(true));\n\t\t\tdownloadsChart.margins().bottom = 30;\n\t\t\tdownloadsChart.margins().right = legendWidth + 5;\n\t\t} else {\n\t\t\tvar legendX = chart_width - legendWidth;\n\n\t\t\tvar chartHeight = 240;\n\t\t\tvar legendHeight = 50 + 13 * top_episodes.length;\n\t\t\tvar padding = 30;\n\t\t\tvar totalHeight = chartHeight + legendHeight + padding\n\n\t\t\tconsole.log({chartHeight, legendHeight, padding, totalHeight});\n\n\t\t\tjQuery(\"#total-chart\").height(totalHeight)\n\t\t\tdownloadsChart.height(totalHeight);\n\t\t\tdownloadsChart.legend(dc.legend().horizontal(false).x(30).y(chartHeight + padding).autoItemWidth(true));\n\t\t\tdownloadsChart.margins().bottom = legendHeight + padding;\n\t\t}\n\n\t\tfor (var index in top_episode_groups) {\n\t\t\tdownloadsChart.stack(top_episode_groups[index], podlove_episode_names[index]);\n\t\t}\n\n\t\tdownloadsChart.yAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tdownloadsChart.xAxis().tickFormat(d3.timeFormat(\"%d %b\"));\n\n\t\t// responsive tick label amounts\n\t\tif (chart_width < 550) {\n\t\t\tdownloadsChart.xAxis().ticks(d3.timeDay, 5);\n\t\t} else if (chart_width < 635) {\n\t\t\tdownloadsChart.xAxis().ticks(d3.timeDay, 4);\n\t\t} else if (chart_width < 780) {\n\t\t\tdownloadsChart.xAxis().ticks(d3.timeDay, 3);\n\t\t} else {\n\t\t\tdownloadsChart.xAxis().ticks(d3.timeDay, 2);\n\t\t}\n\n\t\tdownloadsChart.render();\n\t}\n\n\tfunction load_episode_performance_chart() {\n\n\t\tif (totalsRawData) {\n\t\t\trender_episode_performance_chart();\n\t\t\t$(window).on('resize', render_episode_performance_chart);\n\t\t} else {\n\t\t\t$.when(\n\t\t\t\t$.ajax(ajaxurl + \"?action=podlove-analytics-total-downloads-per-day\")\n\t\t\t).done(function (csvTotals) {\n\n\t\t\t\tvar csvMapper = function (d) {\n\t\t\t\t\tvar parsed_date = new Date(+d.date * 1000);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdate: parsed_date,\n\t\t\t\t\t\tdownloads: +d.downloads,\n\t\t\t\t\t\tepisode_id: +d.episode_id\n\t\t\t\t\t};\n\t\t\t\t};\n\n\t\t\t\ttotalsRawData = d3.csvParse(csvTotals, csvMapper);\n\n\t\t\t\trender_episode_performance_chart();\n\t\t\t\t$(window).on('resize', render_episode_performance_chart);\n\t\t\t});\n\t\t}\n\n\t}\n\n\tif ($(\"#total-chart\").length) {\n\t\tload_episode_performance_chart();\n\t}\n\n\tfunction getLineChart(chart, dimension, group, caption, color) {\n\t\treturn dc.lineChart(chart)\n\t\t\t.dimension(dimension)\n\t\t\t.group(group, caption)\n\t\t\t.colors(color)\n\t\t\t// https://github.com/dc-js/dc.js/issues/615#issuecomment-47771394\n\t\t\t.defined(function(d) { return d.y != null; });\n\t}\n\n\tfunction getBarChart(chart, dimension, group, caption) {\n\t\treturn dc.barChart(chart)\n\t\t\t.dimension(dimension)\n\t\t\t.colors('#cccccc')\n\t\t\t.centerBar(true)\n\t\t\t.group(group, caption);\n\t}\n\n\t// https://github.com/dc-js/dc.js/issues/615#issuecomment-49089248\n\tfunction aboReduceAdd(key) {\n\t\treturn function(p, v){\n\t\t\tif(v[key] === null && p === null){ return null; }\n\t\t\tp += v[key];\n\t\t\treturn p;\n\t\t}\n\t}\n\n\tfunction aboReduceRemove(key) {\n\t\treturn function(p, v){\n\t\t\tif(v[key] === null && p === null){ return null; }\n\t\t\tp -= v[key];\n\t\t\treturn p;\n\t\t}\n\t}\n\n\tfunction aboReduceInit(key) {\n\t\treturn null;\n\t}\n\n\tfunction render_abo_total() {\n\t\tvar chart_width = $(\"#total-abo-chart\").closest(\".wrap\").width();\n\t\tlet xf = crossfilter(aboTotalsRawData)\n\t\tvar episodeDimension = xf.dimension((d) => {\n\t\t\treturn d.number;\n\t\t});\n\n\t\tvar totalDimension = episodeDimension.group().reduceSum((d) => d.downloads);\n\t\tvar q1Dimension = episodeDimension.group().reduce(aboReduceAdd('q1'),  aboReduceRemove('q1'),  aboReduceInit);\n\t\tvar d1Dimension = episodeDimension.group().reduce(aboReduceAdd('d1'),  aboReduceRemove('d1'),  aboReduceInit);\n\t\tvar w1Dimension = episodeDimension.group().reduce(aboReduceAdd('w1'),  aboReduceRemove('w1'),  aboReduceInit);\n\n\t\tlet chart = dc.compositeChart('#total-abo-chart');\n\t\tlet totalChart = getBarChart(chart, episodeDimension, totalDimension, \"Total\");\n\t\tlet q1Chart = getLineChart(chart, episodeDimension, q1Dimension, \"1q\", '#aa0000');\n\t\tlet w1Chart = getLineChart(chart, episodeDimension, w1Dimension, \"1w\", '#8b008b');\n\t\tlet d1Chart = getLineChart(chart, episodeDimension, d1Dimension, \"1d\", '#3a539b');\n\n\t\tchart\n\t\t\t.width(chart_width)\n\t\t\t.x(d3.scaleBand().domain(episodeDimension))\n\t\t\t.xUnits(dc.units.ordinal)\n\t\t\t.elasticX(true)\n\t\t\t.brushOn(false)\n\t\t\t.yAxisLabel('Downloads')\n\t\t\t.group(totalDimension)\n\t\t\t._rangeBandPadding(1) // Fix to align x-axis with points\n\t\t\t.title(function (d) {\n\t\t\t\treturn [\n\t\t\t\t\taboTotalsRawData[d.key].title,\n\t\t\t\t\t\"Downloads: \" + d.value\n\t\t\t\t].join(\"\\n\");\n\t\t\t})\n\t\t\t.renderHorizontalGridLines(true)\n\t\t\t.compose([totalChart, d1Chart, w1Chart, q1Chart]);\n\n\t\t// responsive legend position\n\t\tvar legendWidth = 300;\n\t\tif (chart_width > 650) {\n\t\t\tvar legendX = chart_width - legendWidth;\n\t\t\tjQuery(\"#total-abo-chart\").height(\"200px\")\n\t\t\tchart.height(200);\n\t\t\tchart.legend(dc.legend().horizontal(false).x(legendX).y(10).autoItemWidth(true));\n\t\t\tchart.margins().bottom = 30;\n\t\t\tchart.margins().right = legendWidth + 5;\n\t\t} else {\n\t\t\tvar legendX = chart_width - legendWidth;\n\t\t\tjQuery(\"#total-abo-chart\").height(\"370px\")\n\t\t\tchart.height(370);\n\t\t\tchart.legend(dc.legend().horizontal(false).x(30).y(170).autoItemWidth(true));\n\t\t\tchart.margins().bottom = 30 + 200;\n\t\t}\n\n\t\tchart.render();\n\t}\n\n\tfunction emptyToNull(value) {\n\t\treturn value ? +value : null;\n\t}\n\n\tfunction load_abo_total() {\n\t\tif (aboTotalsRawData) {\n\t\t\trender_abo_total();\n\t\t\t$(window).on('resize', render_abo_total);\n\t\t} else {\n\t\t\t$.when(\n\t\t\t\t$.ajax(ajaxurl + \"?action=podlove-analytics-csv-episodes-table\")\n\t\t\t).done(function (csvTotals) {\n\t\t\t\tvar i = 0;\n\t\t\t\tvar csvMapper = function (d) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tnumber: i++,\n\t\t\t\t\t\ttitle: d.title,\n\t\t\t\t\t\tdownloads: +d.downloads,\n\t\t\t\t\t\td1: emptyToNull(d[\"1d\"]),\n\t\t\t\t\t\td2: emptyToNull(d[\"2d\"]),\n\t\t\t\t\t\tw1: emptyToNull(d[\"1w\"]),\n\t\t\t\t\t\tq1: emptyToNull(d[\"1q\"])\n\t\t\t\t\t};\n\t\t\t\t};\n\n\t\t\t\taboTotalsRawData = d3.csvParse(csvTotals, csvMapper);\n\t\t\t\trender_abo_total();\n\t\t\t\t$(window).on('resize', render_abo_total);\n\t\t\t});\n\t\t}\n\t}\n\n\tif ($(\"#total-abo-chart\").length) {\n\t\tload_abo_total();\n\t}\n\n\n\tvar chartColor = '#69B3FF';\n\n\tconst renderAssetsChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => d.asset);\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.rowChart('#analytics-chart-global-assets')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.colors(chartColor)\n\t\t\t.on('renderlet', (chart) => PODLOVE.Analytics.addPercentageLabels(chart, total))\n\t\t// .on('renderlet', addResetFilter);\n\n\t\tchart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tchart.render();\n\t}\n\n\tconst renderSourcesChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => d.source);\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.rowChart('#analytics-chart-global-sources')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.on('renderlet', (chart) => PODLOVE.Analytics.addPercentageLabels(chart, total))\n\t\t// .on('renderlet', addResetFilter);\n\n\t\tchart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tchart.render();\n\t}\n\n\tconst renderClientsChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => d.client_name);\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.rowChart('#analytics-chart-global-clients')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.cap(10)\n\t\t\t.on('renderlet', (chart) => PODLOVE.Analytics.addPercentageLabels(chart, total))\n\t\t// .on('renderlet', addResetFilter);\n\n\t\tchart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tchart.render();\n\t}\n\n\tconst renderSystemsChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => d.os_name);\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.rowChart('#analytics-chart-global-systems')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.cap(10)\n\t\t\t.on('renderlet', (chart) => PODLOVE.Analytics.addPercentageLabels(chart, total))\n\t\t// .on('renderlet', addResetFilter);\n\n\t\tchart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tchart.render();\n\t}\n\n\tconst renderTopEpisodesChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => d.title);\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.rowChart('#analytics-global-top-episodes')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.othersGrouper(function (data) {\n\t\t\t\treturn data; // no 'others' group\n\t\t\t})\n\t\t\t.colors(chartColor)\n\t\t\t.cap(10)\n\t\t\t.on('renderlet', (chart) => PODLOVE.Analytics.addPercentageLabels(chart, total))\n\t\t// .on('renderlet', addResetFilter);\n\n\t\tchart.xAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\t\tchart.render();\n\t}\n\n\tconst renderPerMonthChart = function (data) {\n\t\tlet xf = crossfilter(data)\n\t\tvar dimension = xf.dimension((d) => {\n\t\t\t[y, m] = d.date_month.split(\" \").map((x) => parseInt(x, 10));\n\t\t\treturn new Date(y, m - 1)\n\t\t});\n\t\tvar group = dimension.group().reduceSum((d) => d.downloads);\n\t\tconst total = xf.groupAll().reduceSum((d) => d.downloads).value();\n\n\t\tlet chart = dc.lineChart('#analytics-chart-global-downloads-per-month')\n\t\t\t.margins({\n\t\t\t\ttop: 0,\n\t\t\t\tleft: 40,\n\t\t\t\tright: 10,\n\t\t\t\tbottom: 25\n\t\t\t})\n\t\t\t.elasticX(true)\n\t\t\t.dimension(dimension) // set dimension\n\t\t\t.group(group) // set group\n\t\t\t.valueAccessor(function (v) {\n\t\t\t\tif (v.value) {\n\t\t\t\t\treturn v.value;\n\t\t\t\t} else {\n\t\t\t\t\treturn 0;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.ordering((v) => -v.value)\n\t\t\t.colors(chartColor)\n\t\t\t.xyTipsOn(true)\n\t\t\t.renderDataPoints({\n\t\t\t\tradius: 3,\n\t\t\t\tfillOpacity: 0.8,\n\t\t\t\tstrokeOpacity: 0.0\n\t\t\t})\n\t\t\t.brushOn(false)\n\t\t\t.title((v) => {\n\t\t\t\treturn v.key.getFullYear() + ' / ' + (v.key.getMonth() + 1) + '\\nDownloads: ' + v.value\n\t\t\t})\n\n\t\tvar domain = dimension.group().all().map((x) => x.key);\n\t\tchart.x(d3.scaleTime().domain(domain))\n\t\tconst spacer = parseInt(domain.length / 5, 10);\n\t\tvar ticks = domain.filter(function (v, i) {\n\t\t\treturn i % spacer === 0;\n\t\t});\n\t\tchart.xAxis().tickValues(ticks);\n\t\tchart.xAxis().tickFormat(d3.timeFormat(\"%b %Y\"));\n\t\tchart.yAxis().tickFormat(PODLOVE.Analytics.formatThousands);\n\n\t\tchart.render();\n\t}\n\n\tconst globalCharts = [{\n\t\tid: \"#analytics-chart-global-assets\",\n\t\taction: 'podlove-analytics-global-assets',\n\t\tmapper: function (d) {\n\t\t\treturn {\n\t\t\t\tdownloads: +d.downloads,\n\t\t\t\tasset: d.asset ? d.asset : 'Unknown'\n\t\t\t}\n\t\t},\n\t\trenderer: renderAssetsChart\n\t}, {\n\t\tid: '#analytics-chart-global-clients',\n\t\taction: 'podlove-analytics-global-clients',\n\t\tmapper: function (d) {\n\t\t\treturn {\n\t\t\t\tdownloads: +d.downloads,\n\t\t\t\tclient_name: d.client_name ? d.client_name : 'Unknown'\n\t\t\t}\n\t\t},\n\t\trenderer: renderClientsChart\n\t}, {\n\t\tid: '#analytics-chart-global-systems',\n\t\taction: 'podlove-analytics-global-systems',\n\t\tmapper: function (d) {\n\t\t\treturn {\n\t\t\t\tdownloads: +d.downloads,\n\t\t\t\tos_name: d.os_name ? d.os_name : 'Unknown'\n\t\t\t}\n\t\t},\n\t\trenderer: renderSystemsChart\n\t}, {\n\t\tid: '#analytics-chart-global-sources',\n\t\taction: 'podlove-analytics-global-sources',\n\t\tmapper: function (d) {\n\t\t\treturn {\n\t\t\t\tdownloads: +d.downloads,\n\t\t\t\tsource: d.source ? d.source : 'Unknown'\n\t\t\t}\n\t\t},\n\t\trenderer: renderSourcesChart\n\t},\n\t// {\n\t// \tid: '#analytics-chart-global-downloads-per-month',\n\t// \taction: 'podlove-analytics-global-downloads-per-month',\n\t// \tmapper: function (d) {\n\t// \t\treturn {\n\t// \t\t\tdownloads: +d.downloads,\n\t// \t\t\tdate_month: d.date_month ? d.date_month : 'Unknown'\n\t// \t\t}\n\t// \t},\n\t// \trenderer: renderPerMonthChart\n\t// },\n\t{\n\t\tid: '#analytics-global-top-episodes',\n\t\taction: 'podlove-analytics-global-top-episodes',\n\t\tmapper: function (d) {\n\t\t\treturn {\n\t\t\t\tdownloads: +d.downloads,\n\t\t\t\ttitle: d.title ? d.title : 'Unknown'\n\t\t\t}\n\t\t},\n\t\trenderer: renderTopEpisodesChart\n\t}]\n\n    const loadDownloadsCount = (from, to) => {\n        const wrapper  = $(\"#analytics-global-downloads\");\n        const valueDiv = $(\"#analytics-global-downloads-value\");\n        const loading  = $(\".chart-loading\", wrapper);\n\n        loading.show();\n        valueDiv.hide();\n\n        $.when(\n            $.ajax(ajaxurl + '?action=podlove-analytics-global-total-downloads' + '&date_from=' + from.toDateString() + '&date_to=' + to.toDateString())\n        ).done((downloadsCount) => {\n            loading.hide();\n            valueDiv.html(downloadsCount);\n            valueDiv.show();\n            wrapper.show();\n        }).fail(() => {\n            loading.hide()\n        });\n    }\n\n    const loadShowsTable = (from, to) => {\n\n        const showsWrapper = $(\"#analytics-global-shows\");\n        let showsChartLoading = $(\".chart-loading\", showsWrapper);\n        let showsChartFailed = $(\".chart-failed\", showsWrapper);\n        let showsChartNoData = $(\".chart-nodata\", showsWrapper);\n        let showsChartContent = $(\".chart-content\", showsWrapper);\n\n        if (showsWrapper.length) {\n\n            showsChartLoading.show();\n            showsChartFailed.hide();\n            showsChartNoData.hide();\n            showsChartContent.hide();\n\n            $.when(\n                $.ajax(ajaxurl + '?action=podlove-analytics-global-total-downloads-by-show' + '&date_from=' + from.toDateString() + '&date_to=' + to.toDateString())\n            ).done((showsDownloadsHtml) => {\n                showsChartLoading.hide();\n                console.log(showsDownloadsHtml)\n                showsChartContent.html(showsDownloadsHtml);\n                showsChartContent.show();\n            }).fail(() => {\n                showsChartLoading.hide();\n                showsChartFailed.show()\n            });\n        }\n    }\n\n\tconst loadGlobalCharts = function (date_range) {\n\n\t\tlet from, to;\n\n\t\tif (date_range && date_range.length) {\n            from = date_range[0]\n\t\t\tto = date_range[1]\n\t\t} else {\n            from = new Date(0)\n\t\t\tto = new Date()\n\t\t}\n\n        loadDownloadsCount(from, to)\n        loadShowsTable(from,to)\n\n\t\tglobalCharts.forEach(chart => {\n\t\t\tlet chartLoading = $(chart.id + \" .chart-loading\")\n\t\t\tlet chartFailed = $(chart.id + \" .chart-failed\")\n\t\t\tlet chartNoData = $(chart.id + \" .chart-nodata\")\n\n\t\t\t$(chart.id).each(function () {\n\n\t\t\t\tchartLoading.show();\n\t\t\t\tchartFailed.hide();\n\t\t\t\tchartNoData.hide();\n\n\t\t\t\t$.when(\n\t\t\t\t\t$.ajax(ajaxurl + '?action=' + chart.action + '&date_from=' + from.toISOString() + '&date_to=' + to.toISOString())\n\t\t\t\t).done((csvAssets) => {\n\n\t\t\t\t\tlet assetData = d3.csvParse(csvAssets, chart.mapper);\n\n\t\t\t\t\tchartLoading.hide();\n\t\t\t\t\tif (!assetData.length) {\n\t\t\t\t\t\tchartNoData.show();\n\t\t\t\t\t}\n\n\t\t\t\t\tlet cb = chart.renderer\n\t\t\t\t\tcb(assetData);\n\t\t\t\t}).fail(() => {\n\t\t\t\t\tchartLoading.hide();\n\t\t\t\t\tchartFailed.show()\n\t\t\t\t});\n\t\t\t})\n\t\t});\n\t}\n\n  if (window.analyticsApp) {\n    window.analyticsApp.$on(\"setChartRange\", function (range) {\n      loadGlobalCharts(range)\n    })\n  }\n});\n"
  },
  {
    "path": "js/src/app.js",
    "content": "import Vue from 'vue'\n\nimport axios from 'axios'\nimport VueAxios from 'vue-axios'\n\nVue.use(VueAxios, axios)\n\nimport JobsDashboard from './components/JobsDashboard'\nimport AnalyticsDatePicker from './components/AnalyticsDatePicker'\nimport Slacknotes from './components/Slacknotes'\nimport Shownotes from './components/Shownotes'\nimport ShownotesEntry from './components/ShownotesEntry'\nimport Draggable from 'vuedraggable'\n\nVue.component('analytics-date-picker', AnalyticsDatePicker)\nVue.component('jobs-dashboard', JobsDashboard)\nVue.component('slacknotes', Slacknotes)\nVue.component('shownotes', Shownotes)\nVue.component('shownotes-entry', ShownotesEntry)\nVue.component('draggable', Draggable)\n\nimport 'v2-datepicker/lib/index.css'\nimport V2Datepicker from 'v2-datepicker'\n\nVue.use(V2Datepicker)\n\nif (document.getElementById('podlove-tools-dashboard')) {\n  const toolsDashboard = new Vue({\n    el: '#podlove-tools-dashboard',\n  })\n}\n\nif (document.getElementById('slacknotes-app')) {\n  window.slacknotes = new Vue({\n    el: '#slacknotes-app',\n  })\n}\n\nif (document.getElementById('podlove-shownotes-app')) {\n  window.shownotes = new Vue({\n    el: '#podlove-shownotes-app',\n  })\n}\n\nif (document.getElementById('podlove-analytics-app')) {\n  window.analyticsApp = new Vue({\n    el: '#podlove-analytics-app',\n  })\n}\n"
  },
  {
    "path": "js/src/components/AnalyticsDatePicker.vue",
    "content": "<template>\n  <div class=\"podlove-analytics-datepicker\">\n    <v2-datepicker-range\n      @change=\"onChange\"\n      lang=\"en\"\n      format=\"YYYY-MM-DD\"\n      v-model=\"range\"\n      :default-value=\"defaultRange\"\n      :picker-options=\"options\"\n    ></v2-datepicker-range>\n  </div>\n</template>\n\n<script>\nconst TIME_DAY = 3600 * 1000 * 24;\n\nconst startOfDay = date => {\n  date.setHours(0, 0, 0, 0);\n  return date;\n};\n\nconst endOfDay = date => {\n  date.setHours(23, 59, 59, 999);\n  return date;\n};\n\nexport default {\n  data: function() {\n    return {\n      range: 0,\n      options: {\n        disabledDate(time) {\n          return time.getTime() > Date.now();\n        },\n        shortcuts: [\n          {\n            text: \"Today\",\n            onClick(picker) {\n              const end = startOfDay(new Date());\n              const start = endOfDay(new Date());\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Yesterday\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n\n              end.setTime(end.getTime() - TIME_DAY);\n              start.setTime(start.getTime() - TIME_DAY);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last week\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 7);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last month\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 30);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last 3 months\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 90);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last Year\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 365);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last 10 Years\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 365 * 10);\n\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          }\n        ]\n      }\n    };\n  },\n  computed: {\n    defaultRange() {\n      const end = new Date();\n      const start = new Date();\n      start.setTime(start.getTime() - TIME_DAY * 30);\n\n      return [startOfDay(start), endOfDay(end)];\n    }\n  },\n  methods: {\n    broadcastRange: function(range) {\n      this.$parent.$emit(\"setChartRange\", range);\n    },\n    onChange: function(range) {\n      this.broadcastRange(range);\n    }\n  },\n  mounted() {\n    this.$nextTick(function() {\n      this.broadcastRange(this.defaultRange);\n    });\n\n    // because chart stuff is rendered 'on load'\n    window.onload = () => {\n        // apparently too early, so give it a little time\n        window.setTimeout(\n            () => { this.broadcastRange(this.defaultRange); },\n            250\n        )\n    };\n  }\n};\n</script>\n"
  },
  {
    "path": "js/src/components/JobsDashboard.vue",
    "content": "<template>\n    <div>\n\n        <h4>Running</h4>\n        <table class=\"widefat striped\">\n            <thead>\n                <tr>\n                    <th>Job Name</th>\n                    <th style=\"width: 175px\">Progress</th>\n                    <th>Created</th>\n                    <th>Last Progress</th>\n                    <th style=\"width: 60px\"></th>\n                </tr>\n            </thead>\n            <tbody>\n                <tr v-for=\"job in runningJobs\">\n                    <td>\n                        {{ job.title }}\n                        <small v-if=\"job.mode\">({{ job.mode }})</small>\n                    </td>\n                    <td>\n                        {{ job.steps_progress }}/{{ job.steps_total }} ({{ job.steps_percent }}%)\n\n                    </td>\n                    <td>{{ job.created_relative }}</td>\n                    <td>{{ job.last_progress }}</td>\n                    <td>\n                        <div v-if=\"isAborting(job)\">\n                            <i class=\"podlove-icon-spinner rotate\"></i>\n                        </div>\n                        <div v-else>\n                            <button class=\"button\" @click=\"abortJob(job)\">abort</button>\n                        </div>\n                    </td>\n                </tr>\n            </tbody>\n        </table>\n\n        <h4>Recently Finished</h4>\n        <table class=\"widefat striped\">\n            <thead>\n                <tr>\n                    <th>Job Name</th>\n                    <th>Finished</th>\n                    <th>Duration</th>\n                </tr>\n            </thead>\n            <tbody>\n                <tr v-for=\"job in finishedJobs\">\n                    <td>\n                        {{ job.title }}\n                        <small v-if=\"job.mode\">({{ job.mode }})</small>\n                    </td>\n                    <td>\n                        {{ job.last_progress }}\n                    </td>\n                    <td>\n                        {{ job.active_run_time }} seconds\n                    </td>\n                </tr>\n            </tbody>\n        </table>\n    </div>\n</template>\n\n<script>\nconst $ = jQuery;\nexport default {\n    data() {\n        return {\n            jobs: [],\n            aborting: []\n        }\n    },\n\n    methods: {\n        fetchJobData() {\n            $.getJSON(ajaxurl, {\n                action: 'podlove-jobs-get'\n            }).done((jobs) => {\n                this.jobs = jobs.map((job) => {\n\n                    job.steps_total = parseInt(job.steps_total, 10);\n                    job.steps_progress = parseInt(job.steps_progress, 10);\n                    job.steps_percent = parseFloat(job.steps_percent);\n                    job.created_at_timestamp = parseInt(job.created_at_timestamp, 10);\n                    job.active_run_time = parseFloat(job.active_run_time);\n\n                    return job;\n                });\n            }).always(() => {\n                window.setTimeout(this.fetchJobData, 3000);\n            });\n        },\n        abortJob(job) {\n            this.aborting.push(job.id)\n            $.getJSON(ajaxurl, {\n                action: 'podlove-job-delete',\n                job_id: job.id,\n                nonce: podlove_admin_global.nonce_ajax\n            })\n        },\n        isAborting(job) {\n            return this.aborting.includes(job.id)\n        }\n    },\n\n    computed: {\n        runningJobs() {\n            return this.jobs.filter((j) => {\n                return j.steps_total > j.steps_progress;\n            }).sort((a, b) => {\n                return a.created_at_timestamp - b.created_at_timestamp;\n            });\n        },\n        finishedJobs() {\n            return this.jobs.filter((j) => {\n                return j.steps_total <= j.steps_progress;\n            }).sort((a, b) => {\n                return b.created_at_timestamp - a.created_at_timestamp;\n            }).slice(0, 20);\n        }\n    },\n\n    mounted() {\n        this.fetchJobData();\n    }\n}\n</script>\n\n<style>\n\n</style>\n"
  },
  {
    "path": "js/src/components/Shownotes.vue",
    "content": "<template>\n  <div class=\"shownotes-wrapper\">\n    <draggable\n      v-if=\"moveModalVisible && topics.length > 0\"\n      id=\"podlove-shownotes-topic-modal\"\n      class=\"\n        move-modal\n        w-[350px]\n        bg-[#000d]\n        text-white text-base\n        rounded-md\n        p-2\n      \"\n      :sort=\"false\"\n      :group=\"{ name: 'foo', pull: false }\"\n    >\n      <div\n        v-for=\"(topic, index) in topics\"\n        :key=\"topic.id\"\n        class=\"\n          flex\n          my-.5\n          py-.5\n          px-2\n          rounded\n          cursor-pointer\n          hover:bg-[#4f9cff]\n          sortable-chosen\n        \"\n      >\n        <div class=\"w-5 mr-1\">{{ index }}</div>\n        <div>{{ topic.title }}</div>\n      </div>\n    </draggable>\n    <div v-if=\"mode == 'import-slacknotes'\">\n      <div class=\"shownotes-modal\">\n        <div class=\"shownotes-modal-content\">\n          <div class=\"header\">\n            <h1>Import from Slacknotes</h1>\n            <div class=\"close\" @click.prevent=\"mode = 'idle'\">\n              <icon-close></icon-close>\n            </div>\n          </div>\n          <div class=\"content\">\n            <slacknotes\n              mode=\"import\"\n              v-on:import:entries=\"onImportEntries\"\n            ></slacknotes>\n          </div>\n        </div>\n      </div>\n      <div class=\"shownotes-modal-backdrop\"></div>\n    </div>\n    <div v-else id=\"shownotes-main\">\n      <draggable\n        v-model=\"shownotes\"\n        @update=\"onDragEnd\"\n        @end=\"onDragEnded\"\n        @clone=\"onClone\"\n        :group=\"{ name: 'foo', pull: 'clone' }\"\n        ghost-class=\"ghost\"\n        handle=\".drag-handle\"\n        :animation=\"100\"\n      >\n        <shownotes-entry\n          :entry=\"entry\"\n          v-on:update:entry=\"onUpdateEntry\"\n          v-on:delete:entry=\"onDeleteEntry\"\n          v-show=\"ready\"\n          v-for=\"entry in visibleShownotes\"\n          :key=\"entry.id\"\n        ></shownotes-entry>\n      </draggable>\n\n      <div class=\"p-expand\" v-if=\"isTruncatedView\">\n        <sn-button\n          :onClick=\"\n            () => {\n              isTruncatedView = false;\n            }\n          \"\n          >Expand to view all Shownotes</sn-button\n        >\n      </div>\n\n      <sn-card v-if=\"mode == 'create'\">\n        <div>\n          <div class=\"p-new-entry\">\n            <h3 style=\"margin-top: 0px\">Add new Entry</h3>\n            <div class=\"p-entry-type-selector\">\n              <span>\n                <input\n                  type=\"radio\"\n                  id=\"entry-type-url\"\n                  value=\"link\"\n                  v-model=\"newEntryType\"\n                />\n                <label for=\"entry-type-url\">Link</label>\n              </span>\n              <span>\n                <input\n                  type=\"radio\"\n                  id=\"entry-type-topic\"\n                  value=\"topic\"\n                  v-model=\"newEntryType\"\n                />\n                <label for=\"entry-type-topic\">Topic</label>\n              </span>\n            </div>\n\n            <div v-if=\"newEntryType == 'link'\" class=\"flex\">\n              <input\n                @keydown.enter.prevent=\"onCreateEntry\"\n                @keydown.esc=\"mode = 'idle'\"\n                v-model=\"newUrl\"\n                type=\"text\"\n                placeholder=\"https://example.com\"\n                :disabled=\"mode == 'create-waiting'\"\n                v-focus\n                class=\"\n                    w-full\n                    shadow-sm\n                    focus:ring-blue-500 focus:border-blue-500\n                    block\n                    sm:text-sm\n                    border border-gray-300\n                    rounded-md\n                \"\n              />\n              <button\n                type=\"button\"\n                class=\"button button-primary\"\n                @click.prevent=\"onCreateEntry\"\n                :disabled=\"mode == 'create-waiting'\"\n              >\n                Add\n              </button>\n            </div>\n            <div v-else-if=\"newEntryType == 'topic'\" class=\"flex\">\n              <input\n                @keydown.enter.prevent=\"onCreateEntry\"\n                @keydown.esc=\"mode = 'idle'\"\n                v-model=\"newTopic\"\n                type=\"text\"\n                placeholder=\"Topic, Subheading\"\n                :disabled=\"mode == 'create-waiting'\"\n                v-focus\n                class=\"\n                    w-full\n                    shadow-sm\n                    focus:ring-blue-500 focus:border-blue-500\n                    block\n                    sm:text-sm\n                    border border-gray-300\n                    rounded-md\n              \"\n              />\n              <button\n                type=\"button\"\n                class=\"button button-primary\"\n                @click.prevent=\"onCreateEntry\"\n                :disabled=\"mode == 'create-waiting'\"\n              >\n                Add\n              </button>\n            </div>\n          </div>\n        </div>\n      </sn-card>\n\n      <div class=\"footer\">\n        <sn-button\n          :onClick=\"\n            () => {\n              isTruncatedView = false;\n              mode = 'create';\n            }\n          \"\n          v-if=\"mode != 'create'\"\n          htmlClass=\"max-w-3xl\"\n        >\n          Add Entry\n        </sn-button>\n\n        <div>\n          <button\n            type=\"button\"\n            class=\"button create-button\"\n            @click.prevent=\"exportAsHTML\"\n            v-if=\"mode != 'create'\"\n          >\n            Export as HTML\n          </button>\n\n          <button\n            type=\"button\"\n            class=\"button create-button\"\n            @click.prevent=\"mode = 'import-slacknotes'\"\n            v-if=\"mode != 'create'\"\n          >\n            Import from Slacknotes\n          </button>\n\n          <button\n            type=\"button\"\n            class=\"button create-button\"\n            @click.prevent=\"importOsfShownotes\"\n            v-if=\"osf_active && mode != 'create'\"\n          >\n            Import OSF Shownotes\n          </button>\n\n          <button\n            type=\"button\"\n            class=\"button create-button\"\n            @click.prevent=\"importHTML\"\n            v-if=\"mode != 'create'\"\n          >\n            Import from Episode HTML\n          </button>\n\n          <button\n            type=\"button\"\n            class=\"button delete-button\"\n            @click.prevent=\"deleteAllEntries\"\n            v-if=\"mode != 'create'\"\n          >\n            Delete all\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nconst $ = jQuery;\nimport Close from \"./icons/Close\";\nimport { saveAs } from \"file-saver\";\nimport { createPopper } from \"@popperjs/core\";\nimport SNButton from \"./shownotes/sn-button.vue\";\nimport SNCard from \"./shownotes/sn-card.vue\";\n\nexport default {\n  props: [\"episodeid\"],\n  data() {\n    return {\n      shownotes: [],\n      ready: false,\n      mode: \"idle\",\n      newUrl: \"\",\n      newTopic: \"\",\n      isTruncatedView: true,\n      truncatedThreshold: 10,\n      newEntryType: \"link\",\n      moveModalVisible: false,\n    };\n  },\n  components: {\n    \"icon-close\": Close,\n    \"sn-button\": SNButton,\n    \"sn-card\": SNCard,\n  },\n  methods: {\n    createEntry: function (url, type, data) {\n      let payload = { type: type, data: data, episode_id: this.episodeid };\n      this.mode = \"create-waiting\";\n\n      if (type == \"link\") {\n        payload.original_url = url;\n      }\n\n      if (type == \"topic\") {\n        payload.title = url;\n      }\n\n      $.post(podlove_vue.rest_url + \"podlove/v1/shownotes\", payload)\n        .done((result) => {\n          this.addIfNew(result);\n          this.newUrl = \"\";\n          this.mode = \"idle\";\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not create entry:\", responseJSON.message);\n          this.mode = \"idle\";\n        });\n    },\n    addIfNew: function (entry) {\n      const isNewLink =\n        entry.type == \"link\" &&\n        this.shownotes.find((e) => e.original_url == entry.original_url) ===\n          undefined;\n      const isTopic = entry.type == \"topic\";\n\n      if (isNewLink || isTopic) this.shownotes.push(entry);\n    },\n    onCreateEntry: function () {\n      if (this.newEntryType == \"link\") {\n        if (!this.newUrl) return;\n\n        this.createEntry(this.newUrl, \"link\");\n        this.newUrl = \"\";\n      }\n\n      if (this.newEntryType == \"topic\") {\n        if (!this.newTopic) return;\n\n        this.createEntry(this.newTopic, \"topic\");\n        this.newTopic = \"\";\n      }\n    },\n    onUpdateEntry: function (entry) {\n      const start = this.shownotes.findIndex((e) => {\n        return e.id == entry.id;\n      });\n      this.shownotes.splice(start, 1, entry);\n    },\n    onDeleteEntry: function (entry) {\n      const start = this.shownotes.findIndex((e) => {\n        return e.id == entry.id;\n      });\n      this.shownotes.splice(start, 1);\n    },\n    onImportEntries: function (entries) {\n      this.mode = \"idle\";\n\n      console.log(\"slack import\", entries);\n\n      let orderNumber = 0;\n      entries.forEach(({ url: url, data: data }) => {\n        orderNumber++;\n        data.orderNumber = orderNumber;\n        this.createEntry(url, \"link\", data);\n      });\n    },\n    // onTopicDragEnd: function (e) {\n    //   console.log(\"onTopicDragEnd\", e);\n    // },\n    onClone: function (e) {\n      if (document.getElementById(\"podlove-shownotes-app\").offsetWidth < 1100) {\n        // hide quicksort UI on small screens\n        return;\n      }\n\n      this.moveModalVisible = true;\n\n      window.setTimeout(() => {\n        // init popper thing\n        const tooltip = document.getElementById(\n          \"podlove-shownotes-topic-modal\"\n        );\n\n        createPopper(e.item.querySelector(\".drag-handle\"), tooltip, {\n          placement: \"right\",\n          modifiers: [\n            {\n              name: \"offset\",\n              options: {\n                offset: [0, 750],\n              },\n            },\n          ],\n        });\n        // console.log(\"onClone\", { e });\n        // console.log(\"onClone\", e.clone);\n      });\n    },\n    onDragEnded: function (e) {\n      const findTopicIndex = (topic) => {\n        return this.shownotes.findIndex(\n          (entry) => entry.type == \"topic\" && entry.title == topic.title\n        );\n      };\n\n      const getNewPosition = (newIndex) => {\n        if (newIndex < 1) {\n          // sort before first topic\n          const nextTopic = this.topics[0];\n          const nextTopicIndex = findTopicIndex(nextTopic);\n          const lastEntryInTopic = this.shownotes[nextTopicIndex - 1];\n\n          if (lastEntryInTopic) {\n            // if there are already items:\n            return (\n              (parseFloat(lastEntryInTopic.position) +\n                parseFloat(nextTopic.position)) /\n              2.0\n            );\n          } else {\n            // if it's the first item:\n            return parseFloat(nextTopic.position) / 2.0;\n          }\n        } else {\n          // sort to a topic\n          const nextTopic = this.topics[newIndex];\n\n          if (nextTopic) {\n            const nextTopicIndex = findTopicIndex(nextTopic);\n            const lastEntryInTopic = this.shownotes[nextTopicIndex - 1];\n\n            return (\n              (parseFloat(lastEntryInTopic.position) +\n                parseFloat(nextTopic.position)) /\n              2.0\n            );\n          } else {\n            // if it's the last topic:\n            return this.shownotes[this.shownotes.length - 1].position + 1;\n          }\n        }\n      };\n\n      this.moveModalVisible = false;\n\n      if (e.to.id !== \"podlove-shownotes-topic-modal\") {\n        return;\n      }\n\n      const newPosition = getNewPosition(e.newIndex);\n      this.shownotes[e.oldIndex].position = newPosition;\n\n      const entry_id = this.shownotes[e.oldIndex].id;\n      $.post(podlove_vue.rest_url + \"podlove/v1/shownotes/\" + entry_id, {\n        id: entry_id,\n        position: newPosition,\n      }).fail(({ responseJSON }) => {\n        console.error(\"could not update entry:\", responseJSON.message);\n      });\n    },\n    onDragEnd: function (e) {\n      let newPosition = null;\n      let prevEl = null;\n      let nextEl = null;\n\n      if (e.oldIndex == e.newIndex) {\n        return;\n      }\n\n      if (e.newIndex == 0) {\n        newPosition = this.shownotes[0].position - 1;\n      } else if (e.newIndex == this.shownotes.length - 1) {\n        newPosition =\n          parseFloat(this.shownotes[this.shownotes.length - 1].position) + 1;\n      } else {\n        if (e.newIndex > e.oldIndex) {\n          prevEl = this.shownotes[e.newIndex];\n          nextEl = this.shownotes[e.newIndex + 1];\n        } else {\n          prevEl = this.shownotes[e.newIndex - 1];\n          nextEl = this.shownotes[e.newIndex];\n        }\n\n        newPosition =\n          (parseFloat(prevEl.position) + parseFloat(nextEl.position)) / 2.0;\n      }\n\n      newPosition = parseFloat(newPosition);\n      this.shownotes[e.oldIndex].position = newPosition;\n\n      const entry_id = this.shownotes[e.oldIndex].id;\n      $.post(podlove_vue.rest_url + \"podlove/v1/shownotes/\" + entry_id, {\n        id: entry_id,\n        position: newPosition,\n      }).fail(({ responseJSON }) => {\n        console.error(\"could not update entry:\", responseJSON.message);\n      });\n    },\n    importOsfShownotes: function () {\n      $.post(podlove_vue.rest_url + \"podlove/v1/shownotes/osf\", {\n        post_id: podlove_vue.post_id,\n      })\n        .done((result) => {\n          this.init(true);\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not import osf:\", responseJSON.message);\n        });\n    },\n    importHTML: function () {\n      $.post(podlove_vue.rest_url + \"podlove/v1/shownotes/html\", {\n        post_id: podlove_vue.post_id,\n      })\n        .done((result) => {\n          this.init();\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not import html:\", responseJSON.message);\n        });\n    },\n    deleteAllEntries: function () {\n      if (window.confirm(\"Permanently delete all shownotes entries?\")) {\n        this.shownotes.forEach((entry) =>\n          jQuery.ajax({\n            url: podlove_vue.rest_url + \"podlove/v1/shownotes/\" + entry.id,\n            method: \"DELETE\",\n            dataType: \"json\",\n          })\n        );\n        this.shownotes = [];\n      }\n    },\n    init: function (forceExpand = false) {\n      $.getJSON(\n        podlove_vue.rest_url +\n          \"podlove/v1/shownotes?episode_id=\" +\n          this.episodeid\n      )\n        .done((shownotes) => {\n          this.shownotes = shownotes;\n          this.ready = true;\n          this.isTruncatedView =\n            this.shownotes.length > this.truncatedThreshold && !forceExpand;\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not load shownotes:\", responseJSON.message);\n        });\n    },\n    exportAsHTML: function () {\n      $.get(podlove_vue.rest_url + \"podlove/v1/shownotes/render/html\", {\n        post_id: podlove_vue.post_id,\n      })\n        .done((result) => {\n          var blob = new Blob([result], { type: \"text/html;charset=utf-8\" });\n          saveAs(blob, \"shownotes.html\");\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not generate html:\", responseJSON.message);\n        });\n    },\n  },\n  computed: {\n    topics: function () {\n      return this.shownotes.filter((entry) => entry.type == \"topic\");\n    },\n    visibleShownotes: function () {\n      let shownotes = this.sortedShownotes;\n\n      if (this.isTruncatedView) {\n        shownotes = shownotes.slice(0, this.truncatedThreshold);\n      }\n\n      return shownotes;\n    },\n    unfurlingProgress: function () {\n      const linkEntries = this.shownotes.filter(\n        (entry) => entry.type == \"link\"\n      );\n      const linkCount = linkEntries.length;\n\n      if (!linkCount) {\n        return 100;\n      }\n\n      const unfurlingCount = linkEntries.filter(\n        (entry) => entry.state == \"unfurling\"\n      ).length;\n      const progressPercent = Math.floor(\n        (100 * (linkCount - unfurlingCount)) / linkCount\n      );\n\n      return progressPercent;\n    },\n    sortedShownotes: function () {\n      return this.shownotes.sort((a, b) => {\n        return a.position - b.position;\n      });\n    },\n    osf_active: function () {\n      return podlove_vue.osf_active;\n    },\n  },\n  directives: {\n    focus: {\n      inserted: function (el) {\n        el.focus();\n      },\n    },\n  },\n  mounted: function () {\n    this.init();\n  },\n};\n</script>\n\n<style>\n#podlove_podcast_shownotes .inside {\n  background: #f9f9f9;\n  margin-top: 0;\n  padding-top: 6px;\n}\n\n#podlove_podcast_shownotes a {\n  color: inherit;\n  text-decoration: inherit;\n}\n\n.sortable-chosen.ghost > div > div > div {\n  background-color: #eee;\n}\n.sortable-chosen.ghost > div > div > div > div {\n  visibility: hidden;\n}\n\n/* BEGIN shownotes modal */\n.shownotes-modal-backdrop {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  min-height: 360px;\n  background: #000;\n  opacity: 0.7;\n  z-index: 119900;\n}\n.shownotes-modal {\n  position: fixed;\n  top: 100px;\n  left: 30px;\n  right: 30px;\n  bottom: 30px;\n  z-index: 120000;\n}\n.shownotes-modal * {\n  box-sizing: content-box;\n}\n.shownotes-modal-content {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  overflow: auto;\n  min-height: 300px;\n  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.7);\n  background: #fcfcfc;\n  -webkit-font-smoothing: subpixel-antialiased;\n}\n.shownotes-modal-content {\n  padding: 0 12px 12px 12px;\n}\n@media screen and (min-width: 960px) {\n  .shownotes-modal-content {\n    padding: 0 12px 12px 150px;\n  }\n}\n.shownotes-modal-content .header {\n  display: flex;\n  justify-content: space-between;\n}\n.shownotes-modal-content .header .close {\n  width: 50px;\n  height: 50px;\n  cursor: pointer;\n}\n.shownotes-modal .shownotes-modal-content h1 {\n  padding: 0 16px;\n  font-size: 22px;\n  line-height: 50px;\n  margin: 0;\n}\n/* END shownotes modal */\n\n/* BEGIN temporary rules until converted to Tailwind */\n#podlove_podcast_shownotes .p-expand {\n  margin: 40px 24px;\n}\n\n#podlove_podcast_shownotes .footer {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  margin: 0px 24px;\n}\n\n/* END temporary rules */\n\n#podlove_podcast_shownotes *,\n#podlove_podcast_shownotes :before,\n#podlove_podcast_shownotes :after {\n  box-sizing: border-box;\n  border-width: 0;\n  border-style: solid;\n  border-color: currentColor;\n}\n\n.left-\\[1070px\\] {\n  left: 1070px;\n}\n\n.top-\\[160px\\] {\n  top: 160px;\n}\n\n.mx-5 {\n  margin-left: 1.25rem;\n  margin-right: 1.25rem;\n}\n\n.my-3 {\n  margin-top: 0.75rem;\n  margin-bottom: 0.75rem;\n}\n\n.mx-\\[3\\.125rem\\] {\n  margin-left: 3.125rem;\n  margin-right: 3.125rem;\n}\n\n.mr-1 {\n  margin-right: 0.25rem;\n}\n\n.mt-8 {\n  margin-top: 2rem;\n}\n\n.mb-0 {\n  margin-bottom: 0;\n}\n\n.mt-1 {\n  margin-top: 0.25rem;\n}\n\n.ml-3 {\n  margin-left: 0.75rem;\n}\n\n.mt-2\\.5 {\n  margin-top: 0.625rem;\n}\n\n.mt-2 {\n  margin-top: 0.5rem;\n}\n\n.mt-6 {\n  margin-top: 1.5rem;\n}\n\n.block {\n  display: block;\n}\n\n.flex {\n  display: flex;\n}\n\n.inline-flex {\n  display: inline-flex;\n}\n\n.hidden {\n  display: none;\n}\n\n.h-5 {\n  height: 1.25rem;\n}\n\n.h-8 {\n  height: 2rem;\n}\n\n.h-4 {\n  height: 1rem;\n}\n\n.h-6 {\n  height: 1.5rem;\n}\n\n.max-h-48 {\n  max-height: 12rem;\n}\n\n.w-\\[350px\\] {\n  width: 350px;\n}\n\n.w-5 {\n  width: 1.25rem;\n}\n\n.w-full {\n  width: 100%;\n}\n\n.w-4 {\n  width: 1rem;\n}\n\n.w-\\[300px\\] {\n  width: 300px;\n}\n\n.w-36 {\n  width: 9rem;\n}\n\n.max-w-3xl {\n  max-width: 48rem;\n}\n\n.flex-shrink-0 {\n  flex-shrink: 0;\n}\n\n.flex-grow {\n  flex-grow: 1;\n}\n\n.origin-center {\n  transform-origin: center;\n}\n\n.transform {\n  transform: var(--tw-transform);\n}\n\n@-webkit-keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n.animate-spin {\n  -webkit-animation: spin 1s linear infinite;\n  animation: spin 1s linear infinite;\n}\n\n@-webkit-keyframes pulse {\n  50% {\n    opacity: 0.5;\n  }\n}\n\n@keyframes pulse {\n  50% {\n    opacity: 0.5;\n  }\n}\n\n.animate-pulse {\n  -webkit-animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n.cursor-pointer {\n  cursor: pointer;\n}\n\n.cursor-move {\n  cursor: move;\n}\n\n.items-center {\n  align-items: center;\n}\n\n.justify-end {\n  justify-content: flex-end;\n}\n\n.justify-center {\n  justify-content: center;\n}\n\n.justify-between {\n  justify-content: space-between;\n}\n\n.gap-3 {\n  gap: 0.75rem;\n}\n\n.gap-1 {\n  gap: 0.25rem;\n}\n\n.gap-2\\.5 {\n  gap: 0.625rem;\n}\n\n.gap-2 {\n  gap: 0.5rem;\n}\n\n.truncate {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rounded-md {\n  border-radius: 0.375rem;\n}\n\n.rounded {\n  border-radius: 0.25rem;\n}\n\n#podlove_podcast_shownotes .border {\n  border-width: 1px;\n}\n\n#podlove_podcast_shownotes .border-b-2 {\n  border-bottom-width: 2px;\n}\n\n#podlove_podcast_shownotes .border-b {\n  border-bottom-width: 1px;\n}\n\n#podlove_podcast_shownotes .border-l-4 {\n  border-left-width: 4px;\n}\n\n#podlove_podcast_shownotes .border-gray-800 {\n  --tw-border-opacity: 1;\n  border-color: rgb(31 41 55 / var(--tw-border-opacity));\n}\n\n#podlove_podcast_shownotes .border-gray-300 {\n  --tw-border-opacity: 1;\n  border-color: rgb(209 213 219 / var(--tw-border-opacity));\n}\n\n#podlove_podcast_shownotes .border-transparent {\n  border-color: transparent;\n}\n\n#podlove_podcast_shownotes .border-red-400 {\n  --tw-border-opacity: 1;\n  border-color: rgb(248 113 113 / var(--tw-border-opacity));\n}\n\n.bg-\\[\\#000d\\] {\n  background-color: #000d;\n}\n\n.bg-white {\n  --tw-bg-opacity: 1;\n  background-color: rgb(255 255 255 / var(--tw-bg-opacity));\n}\n\n.bg-red-50 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(254 242 242 / var(--tw-bg-opacity));\n}\n\n.bg-blue-600 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(37 99 235 / var(--tw-bg-opacity));\n}\n\n.bg-gray-300 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(209 213 219 / var(--tw-bg-opacity));\n}\n\n.bg-gray-100 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(243 244 246 / var(--tw-bg-opacity));\n}\n\n.p-2 {\n  padding: 0.5rem;\n}\n\n.p-4 {\n  padding: 1rem;\n}\n\n.px-2 {\n  padding-left: 0.5rem;\n  padding-right: 0.5rem;\n}\n\n.px-3 {\n  padding-left: 0.75rem;\n  padding-right: 0.75rem;\n}\n\n.py-2\\.5 {\n  padding-top: 0.625rem;\n  padding-bottom: 0.625rem;\n}\n\n.py-2 {\n  padding-top: 0.5rem;\n  padding-bottom: 0.5rem;\n}\n\n.px-4 {\n  padding-left: 1rem;\n  padding-right: 1rem;\n}\n\n.pt-5 {\n  padding-top: 1.25rem;\n}\n\n.text-base {\n  font-size: 1rem;\n  line-height: 1.5rem;\n}\n\n.text-sm {\n  font-size: 0.875rem;\n  line-height: 1.25rem;\n}\n\n.font-bold {\n  font-weight: 700;\n}\n\n.font-medium {\n  font-weight: 500;\n}\n\n.text-white {\n  --tw-text-opacity: 1;\n  color: rgb(255 255 255 / var(--tw-text-opacity));\n}\n\n.text-gray-500 {\n  --tw-text-opacity: 1;\n  color: rgb(107 114 128 / var(--tw-text-opacity));\n}\n\n.text-gray-700 {\n  --tw-text-opacity: 1;\n  color: rgb(55 65 81 / var(--tw-text-opacity));\n}\n\n.text-red-700 {\n  --tw-text-opacity: 1;\n  color: rgb(185 28 28 / var(--tw-text-opacity));\n}\n\n.text-gray-800 {\n  --tw-text-opacity: 1;\n  color: rgb(31 41 55 / var(--tw-text-opacity));\n}\n\n.text-gray-400 {\n  --tw-text-opacity: 1;\n  color: rgb(156 163 175 / var(--tw-text-opacity));\n}\n\n.text-red-400 {\n  --tw-text-opacity: 1;\n  color: rgb(248 113 113 / var(--tw-text-opacity));\n}\n\n.opacity-60 {\n  opacity: 0.6;\n}\n\n#podlove_podcast_shownotes .shadow {\n  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px 0 rgb(0 0 0 / 0.06);\n  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),\n    var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n}\n\n#podlove_podcast_shownotes .shadow-sm {\n  --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),\n    var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n}\n\n.line-clamp-4 {\n  overflow: hidden;\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 4;\n}\n\n.hover\\:bg-\\[\\#4f9cff\\]:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(79 156 255 / var(--tw-bg-opacity));\n}\n\n.hover\\:bg-red-200:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(254 202 202 / var(--tw-bg-opacity));\n}\n\n.hover\\:bg-gray-50:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(249 250 251 / var(--tw-bg-opacity));\n}\n\n.hover\\:bg-blue-700:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(29 78 216 / var(--tw-bg-opacity));\n}\n\n.focus\\:border-blue-500:focus {\n  --tw-border-opacity: 1;\n  border-color: rgb(59 130 246 / var(--tw-border-opacity));\n}\n\n.focus\\:outline-none:focus {\n  outline: 2px solid transparent;\n  outline-offset: 2px;\n}\n\n.focus\\:ring-2:focus {\n  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0\n    var(--tw-ring-offset-width) var(--tw-ring-offset-color);\n  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0\n    calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);\n  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),\n    var(--tw-shadow, 0 0 #0000);\n}\n\n.focus\\:ring-blue-500:focus {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));\n}\n\n.focus\\:ring-red-500:focus {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));\n}\n\n.focus\\:ring-offset-2:focus {\n  --tw-ring-offset-width: 2px;\n}\n\n@media (min-width: 640px) {\n  .sm\\:text-sm {\n    font-size: 0.875rem;\n    line-height: 1.25rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "js/src/components/ShownotesEntry.vue",
    "content": "<template>\n  <div class=\"max-w-3xl\">\n    <div v-if=\"entry.type == 'link'\">\n      <shownotes-link v-bind:entry=\"entry\"></shownotes-link>\n    </div>\n    <div v-if=\"entry.type == 'topic'\">\n      <shownotes-topic v-bind:entry=\"entry\"></shownotes-topic>\n    </div>\n  </div>\n</template>\n\n<script>\nimport ShownotesTopic from \"./shownotes/topic\";\nimport ShownotesLink from \"./shownotes/link\";\n\nexport default {\n  props: [\"entry\"],\n  data() {\n    return {};\n  },\n  computed: {\n    isHidden: function () {\n      return this.entry.hidden === \"1\";\n    },\n  },\n  components: {\n    \"shownotes-topic\": ShownotesTopic,\n    \"shownotes-link\": ShownotesLink,\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/Slacknotes.vue",
    "content": "<template>\n  <div>\n    <div class=\"p-card\" v-if=\"!module_active\">\n      <div class=\"p-card-header\">\n        <strong>Slacknotes module inactive</strong>\n      </div>\n      <div\n        class=\"p-card-body\"\n      >You need to activate and setup the Slacknotes Publisher Module before you can use this import function.</div>\n    </div>\n    <div class=\"p-card\" v-if=\"!module_token_set\">\n      <div class=\"p-card-header\">\n        <strong>Slack OAuth Access Token missing</strong>\n      </div>\n      <div\n        class=\"p-card-body\"\n      >You need to set the Slack OAuth Access Token in the Slacknotes Publisher Module settings before you can use this import function.</div>\n    </div>\n    <div class=\"p-card\" v-if=\"channelsLoading && module_active && module_token_set\">\n      <div class=\"p-card-header\">\n        <strong>Loading channels ...</strong>\n      </div>\n      <div class=\"p-card-body\" style=\"display: flex; justify-content: center;\">\n        <i class=\"podlove-icon-spinner rotate\" style=\"margin: 35px 0;\"></i>\n      </div>\n    </div>\n    <div class=\"p-card\" v-else-if=\"module_active && module_token_set\">\n      <div class=\"p-card-header\">\n        <strong>Select Slack Channel</strong>\n      </div>\n      <div class=\"p-card-body slacknotes-toolbar\">\n        <div style=\"display: flex\">\n          <select v-model=\"currentChannel\" @change=\"fetchLinks\">\n            <option value=\"0\">Select Slack Channel</option>\n            <option\n              v-for=\"channel in channels\"\n              :value=\"channel.id\"\n              :key=\"channel.id\"\n            >#{{ channel.name }}</option>\n          </select>\n\n          <v2-datepicker-range\n            v-model=\"dates\"\n            @change=\"onDatepickerChange\"\n            lang=\"en\"\n            :default-value=\"defaultRange\"\n            :picker-options=\"options\"\n          ></v2-datepicker-range>\n        </div>\n\n        <div class=\"button-group\">\n          <button\n            @click=\"setOrder('desc')\"\n            type=\"button\"\n            :class=\"{'button-active': order == 'desc'}\"\n            class=\"button\"\n          >Newest First</button>\n          <button\n            @click=\"setOrder('asc')\"\n            type=\"button\"\n            :class=\"{'button-active': order == 'asc'}\"\n            class=\"button\"\n          >Oldest First</button>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"p-card slack-links-empty\" v-if=\"!channelsLoading && !linksReady\">\n      <div class=\"p-card-header\">\n        <strong>Loading links ...</strong>\n      </div>\n      <div class=\"p-card-body\" style=\"display: flex; justify-content: center;\">\n        <i class=\"podlove-icon-spinner rotate\" style=\"margin: 35px 0;\"></i>\n      </div>\n    </div>\n\n    <div v-if=\"linksReady && links.length == 0\" class=\"p-card slack-links-empty\">\n      <div class=\"p-card-body\">There are no links for the selected channel.</div>\n    </div>\n\n    <div class=\"slack-links\" v-show=\"linksReady\">\n      <div\n        class=\"p-card slack-link\"\n        v-bind:class=\"{ excluded: link.excluded }\"\n        v-for=\"link in sortedLinks\"\n        :key=\"link.id\"\n      >\n        <div class=\"p-card-body\" style=\"display: flex; justify-content: space-between\">\n          <div class=\"slack-link-select\">\n            <input type=\"checkbox\" :checked=\"!link.excluded\" @change=\"toggleExclusion(link)\">\n          </div>\n          <div class=\"slack-link-content\" style=\"flex-grow: 10\">\n            <div class=\"slack-card-headline\">\n              <span class=\"link-title\" v-if=\"link.title\">{{ link.title }}</span>\n              <span v-else>\n                <span class=\"bar-loading\" v-if=\"isFetching(link)\"></span>\n                <a\n                  href=\"#\"\n                  class=\"unknown-title\"\n                  @click.prevent=\"fetchLinkTitle(link)\"\n                  v-if=\"!isFetching(link)\"\n                >try to fetch title from website</a>\n              </span>\n              <span class=\"link-source\" v-if=\"link.source\">{{ link.source }}</span>\n            </div>\n            <span class=\"link-url\" v-if=\"link.link\">\n              <a :href=\"link.link\" target=\"_blank\">{{ link.link }}</a>\n            </span>\n          </div>\n          <div class=\"slack-link-date\">\n            {{ linkDate(link) }}\n            <br>\n            {{ linkTime(link) }}\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"output-container p-card\" v-if=\"mode != 'import'\" v-show=\"linksReady\">\n      <div class=\"output-header p-card-header\">\n        <strong>Shownotes HTML</strong>\n      </div>\n      <pre class=\"output p-card-body\" id=\"clipboard-target\">{{ renderedHTML }}</pre>\n      <div class=\"output-footer p-card-header\" style=\"vertical-align: baseline; line-height: 28px;\">\n        <span class=\"button clipboard-btn\" data-clipboard-target=\"#clipboard-target\">Copy HTML</span>\n        <transition name=\"fade\">\n          <span\n            v-show=\"showCopySuccess\"\n            class=\"copy-success\"\n            style=\"padding-left: 8px; color: #2c6e36; text-shadow: 1px 1px 1px white; display: none;\"\n          >Shownotes copied to clipboard</span>\n        </transition>\n      </div>\n    </div>\n\n    <div\n      class=\"output-footer p-card-header\"\n      style=\"margin-bottom: 12px;\"\n      v-show=\"mode == 'import' && linksReady\"\n    >\n      <span\n        class=\"button button-primary\"\n        @click=\"importToEpisode()\"\n      >Import {{ this.entriesForImport.length }} Entries</span>\n    </div>\n  </div>\n</template>\n\n<script>\nconst $ = jQuery;\n\nimport ClipboardJS from \"clipboard\";\n\nconst TIME_DAY = 3600 * 1000 * 24;\nconst startOfDay = date => {\n  date.setHours(0, 0, 0, 0);\n  return date;\n};\nconst endOfDay = date => {\n  date.setHours(23, 59, 59, 999);\n  return date;\n};\n\nexport default {\n  props: [\"mode\"],\n  data() {\n    return {\n      channels: [],\n      channelsLoading: true,\n      links: [],\n      linksLoading: false,\n      initializing: true,\n      currentChannel: null,\n      showCopySuccess: false,\n      module_active: true,\n      module_token_set: true,\n      dates: [],\n      fetching: [],\n      order: \"desc\",\n      options: {\n        disabledDate(time) {\n          return time.getTime() > Date.now();\n        },\n        shortcuts: [\n          {\n            text: \"Today\",\n            onClick(picker) {\n              const end = startOfDay(new Date());\n              const start = endOfDay(new Date());\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Yesterday\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              end.setTime(end.getTime() - TIME_DAY);\n              start.setTime(start.getTime() - TIME_DAY);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last week\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 7);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last month\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 30);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last 3 months\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 90);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last Year\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 365);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          },\n          {\n            text: \"Last 10 Years\",\n            onClick(picker) {\n              const end = new Date();\n              const start = new Date();\n              start.setTime(start.getTime() - TIME_DAY * 365 * 10);\n              picker.$emit(\"pick\", [startOfDay(start), endOfDay(end)]);\n            }\n          }\n        ]\n      }\n    };\n  },\n\n  computed: {\n    defaultRange() {\n      const end = new Date();\n      const start = new Date();\n      start.setTime(start.getTime() - TIME_DAY * 30);\n      return [startOfDay(start), endOfDay(end)];\n    },\n    apiUrlMessages: function() {\n      return (\n        podlove_vue.rest_url +\n        \"podlove/v1/slacknotes/\" +\n        this.currentChannel +\n        \"/messages\"\n      );\n    },\n    renderedHTML: function() {\n      let html = \"<ul>\\n\";\n\n      for (let j = 0; j < this.sortedLinks.length; j++) {\n        const link = this.links[j];\n        const title = link.title ? link.title : link.link;\n\n        if (!link.excluded) {\n          html +=\n            '    <li><a href=\"' +\n            link.link +\n            '\">' +\n            title +\n            \"</a> (\" +\n            link.source +\n            \")</li>\\n\";\n        }\n      }\n\n      html += \"</ul>\\n\";\n\n      return html;\n    },\n    entriesForImport: function() {\n      return this.sortedLinks\n        .filter(l => !l.excluded)\n        .map(l => {\n          return { url: l.link, data: l };\n        });\n    },\n    linksReady: function() {\n      return !this.initializing && this.links != [] && !this.linksLoading;\n    },\n    sortedLinks: function() {\n      return this.links.sort((a, b) => {\n        if (this.order == \"asc\") {\n          return a.unix_date - b.unix_date;\n        } else {\n          return b.unix_date - a.unix_date;\n        }\n      });\n    }\n  },\n\n  methods: {\n    importToEpisode: function() {\n      let entries = this.entriesForImport;\n      this.$emit(\"import:entries\", entries);\n    },\n    onDatepickerChange: function(range) {\n      if (range.length == 2) {\n        this.dates = [startOfDay(range[0]), endOfDay(range[1])];\n      }\n\n      this.fetchLinks();\n    },\n    setOrder: function(order) {\n      this.order = order;\n\n      if (localStorage) {\n        localStorage.setItem(\"podlove-slacknotes-order\", this.order);\n      }\n    },\n    fetchLinks: function() {\n      if (this.currentChannel) {\n        this.linksLoading = true;\n\n        if (localStorage) {\n          localStorage.setItem(\n            \"podlove-slacknotes-channel\",\n            this.currentChannel\n          );\n        }\n\n        let date_from = 0;\n        let date_to = 0;\n\n        if (this.dates.length === 2) {\n          date_from = this.dates[0].getTime() / 1000;\n          date_to = this.dates[1].getTime() / 1000;\n        }\n\n        $.ajax({\n          url: this.apiUrlMessages,\n          data: {\n            date_from: date_from,\n            date_to: date_to\n          }\n        })\n          .done(data => {\n            const reduceMessagesToLinks = function(links, message) {\n              for (let i = 0; i < message.links.length; i++) {\n                const unix_date =\n                  parseInt(message.raw_slack_message.ts, 10) * 1000;\n                const datetime = new Date(unix_date);\n                const link = message.links[i];\n\n                link.unix_date = unix_date;\n                link.datetime = datetime;\n                link.id = unix_date + link.link;\n\n                links.push(link);\n              }\n\n              return links;\n            };\n\n            const addExcludedField = function(link) {\n              link.excluded = false;\n              return link;\n            };\n\n            const links = data\n              .reduce(reduceMessagesToLinks, [])\n              .map(addExcludedField);\n\n            this.links = links;\n            this.linksLoading = false;\n            this.initializing = false;\n\n            // fetch missing titles\n            window.setTimeout(() => {\n              let list = document.getElementsByClassName(\"unknown-title\");\n\n              for (let i = 0; i < list.length; i++) {\n                const element = list[i];\n                element.click();\n              }\n            }, 200);\n          })\n          .fail(e => console.error(\"Slacknotes failed fetching messages\", e));\n      } else {\n        this.messages = [];\n      }\n    },\n    isFetching: function(link) {\n      const url = link.link;\n      return this.fetching.includes(url);\n    },\n    fetchLinkTitle: function(link) {\n      const url = link.link;\n\n      this.fetching.push(url);\n\n      $.ajax(\n        podlove_vue.rest_url +\n          \"podlove/v1/slacknotes/resolve_url?url=\" +\n          encodeURIComponent(url)\n      ).done(data => {\n        if (data.title) {\n          link.title = data.title;\n        }\n\n        if (data.url) {\n          link.link = data.url;\n        }\n\n        // delete from fetching\n        var index = this.fetching.indexOf(url);\n        if (index !== -1) this.fetching.splice(index, 1);\n      });\n    },\n    toggleExclusion: function(link) {\n      link.excluded = !link.excluded;\n    },\n    linkDate: function(link) {\n      const date = link.datetime;\n      const y = date.getFullYear();\n      const m = date.getMonth() + 1;\n      const d = date.getUTCDate();\n\n      return d + \".\" + m + \".\" + y;\n    },\n    linkTime: function(link) {\n      const date = link.datetime;\n      const h = date.getHours();\n      let m = date.getMinutes();\n\n      if (m < 10) {\n        m = \"0\" + m;\n      }\n\n      return h + \":\" + m;\n    }\n  },\n\n  mounted() {\n    $.when($.ajax(podlove_vue.rest_url + \"podlove/v1/slacknotes/channels\"))\n      .done(channelData => {\n        this.channels = channelData;\n        this.channelsLoading = false;\n\n        let savedChannel = null;\n        let savedOrder = null;\n\n        if (localStorage) {\n          savedChannel = localStorage.getItem(\"podlove-slacknotes-channel\");\n          savedOrder = localStorage.getItem(\"podlove-slacknotes-order\");\n        }\n\n        if (savedChannel) {\n          this.currentChannel = savedChannel;\n        }\n\n        if (savedOrder) {\n          this.order = savedOrder;\n        }\n\n        this.fetchLinks();\n      })\n      .fail(e => {\n        console.error(\"Slacknotes failed fetching channels\", e);\n\n        if (e.responseJSON.code == \"rest_no_route\") {\n          this.module_active = false;\n        }\n        if (e.responseJSON.code == \"podlove_slacknotes_no_token\") {\n          this.module_token_set = false;\n        }\n      });\n\n    let clip = new ClipboardJS(\".clipboard-btn\");\n    clip.on(\"success\", e => {\n      this.showCopySuccess = true;\n      window.setTimeout(() => {\n        this.showCopySuccess = false;\n      }, 3000);\n    });\n  }\n};\n</script>\n\n<style>\n.v2-picker-panel-wrap {\n  z-index: 150000 !important;\n}\n</style>\n\n<style scoped>\n.slack-links,\n.slack-links-empty {\n  margin-top: 2rem;\n}\n\n.slack-link {\n  margin-bottom: 6px;\n}\n\n.slack-link.excluded {\n  opacity: 0.5;\n}\n\n.p-card {\n  background: white;\n  border: 1px solid #ddd;\n}\n\n.p-card-body {\n  padding: 12px;\n}\n\n.slack-card-headline {\n  margin-bottom: 6px;\n}\n\n.p-card-header,\n.p-card-footer {\n  background: #e9e9e9;\n  padding: 12px;\n}\n\n.slack-link-select {\n  margin-right: 6px;\n}\n\n.link-title {\n  font-weight: bold;\n  font-size: 15px;\n}\n.link-source {\n  font-style: italic;\n}\n.link-url a {\n  text-decoration: none;\n}\n\n.output-container {\n  margin-top: 2rem;\n}\n\n.output {\n  font-family: monospace;\n  line-height: 22px;\n  margin: 0;\n  overflow-x: auto;\n}\n\n.slack-link-date {\n  padding-left: 6px;\n  text-align: right;\n}\n\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 0.5s;\n}\n.fade-enter,\n.fade-leave-to {\n  opacity: 0;\n}\n\n.bar-loading {\n  animation: colorchange ease-in 2s running infinite;\n  display: inline-block;\n  background: #999;\n  width: 250px;\n  height: 12px;\n  border-radius: 3px;\n}\n\n@keyframes colorchange {\n  0% {\n    background: #ccc;\n  }\n  50% {\n    background: #ddd;\n  }\n  100% {\n    background: #ccc;\n  }\n}\n\n.button-group .button.button-active {\n  background: #eee;\n  border-color: #999;\n  box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5);\n  transform: translateY(1px);\n}\n\n.slacknotes-toolbar {\n  display: flex;\n  justify-content: space-between;\n}\n</style>\n"
  },
  {
    "path": "js/src/components/icons/CheveronDown.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon-cheveron-down\">\n    <path\n      class=\"secondary\"\n      fill-rule=\"evenodd\"\n      d=\"M15.3 10.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4l3.3 3.29 3.3-3.3z\"\n    ></path>\n  </svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/CheveronUp.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon-cheveron-up\">\n    <path\n      class=\"secondary\"\n      fill-rule=\"evenodd\"\n      d=\"M8.7 13.7a1 1 0 1 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1-1.4 1.4L12 10.42l-3.3 3.3z\"\n    ></path>\n  </svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/Close.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon-close\">\n    <path\n      class=\"secondary\"\n      fill-rule=\"evenodd\"\n      d=\"M15.78 14.36a1 1 0 0 1-1.42 1.42l-2.82-2.83-2.83 2.83a1 1 0 1 1-1.42-1.42l2.83-2.82L7.3 8.7a1 1 0 0 1 1.42-1.42l2.83 2.83 2.82-2.83a1 1 0 0 1 1.42 1.42l-2.83 2.83 2.83 2.82z\"\n    ></path>\n  </svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/DotsVertical.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon-dots-vertical\">\n    <path\n      class=\"secondary\"\n      fill-rule=\"evenodd\"\n      d=\"M12 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4z\"\n    ></path>\n  </svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/Edit.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    :class=\"htmlClass\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    stroke-width=\"2\"\n    stroke-linecap=\"round\"\n    stroke-linejoin=\"round\"\n  >\n    <path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path>\n    <path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>\n  </svg>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/icons/Eye.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    :class=\"htmlClass\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    stroke-width=\"2\"\n    stroke-linecap=\"round\"\n    stroke-linejoin=\"round\"\n  >\n    <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path>\n    <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n  </svg>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/icons/EyeOff.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    :class=\"htmlClass\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke=\"currentColor\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      stroke-width=\"2\"\n      d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\"\n    />\n  </svg>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/icons/Image.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-image\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"></circle><polyline points=\"21 15 16 10 5 21\"></polyline></svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/Link.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-link\"><path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path></svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/Menu.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    :class=\"htmlClass\"\n    viewBox=\"0 0 20 20\"\n    fill=\"currentColor\"\n  >\n    <path\n      fill-rule=\"evenodd\"\n      d=\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\"\n      clip-rule=\"evenodd\"\n    />\n  </svg>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/icons/Refresh.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-refresh-ccw\"><polyline points=\"1 4 1 10 7 10\"></polyline><polyline points=\"23 20 23 14 17 14\"></polyline><path d=\"M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15\"></path></svg>\n</template>\n"
  },
  {
    "path": "js/src/components/icons/Type.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-type\"><polyline points=\"4 7 4 4 20 4 20 7\"></polyline><line x1=\"9\" y1=\"20\" x2=\"15\" y2=\"20\"></line><line x1=\"12\" y1=\"4\" x2=\"12\" y2=\"20\"></line></svg>\n</template>\n"
  },
  {
    "path": "js/src/components/shownotes/link-compact.vue",
    "content": "<template>\n  <sn-card :htmlClass=\"isHidden ? 'bg-gray-100 opacity-60' : ''\">\n    <div class=\"flex items-center justify-between\">\n      <div class=\"flex items-center gap-3 text-gray-500 truncate\">\n        <icon-menu htmlClass=\"w-5 h-5 flex-shrink-0 cursor-move drag-handle\" />\n        <div class=\"truncate\">\n          <div class=\"flex items-center gap-1\">\n            <img\n              v-if=\"entry && entry.icon\"\n              :src=\"icon\"\n              width=\"16\"\n              height=\"16\"\n            />\n            <div v-else style=\"width: 16px; height: 16px\"></div>\n\n            <span class=\"text-sm truncate text-gray-800 font-bold\">\n              <a :href=\"entry.url\" target=\"_blank\">{{ entry.title }}</a>\n            </span>\n          </div>\n        </div>\n      </div>\n      <div class=\"flex items-center gap-2.5 text-gray-500\">\n        <span\n          v-if=\"!isHidden\"\n          title=\"hide (hidden items do not appear in public shownotes)\"\n          @click.prevent=\"$emit('toggleHidden')\"\n        >\n          <icon-eye htmlClass=\"w-5 h-5 cursor-pointer\" />\n        </span>\n        <span\n          v-else\n          title=\"show (hidden items do not appear in public shownotes)\"\n          @click.prevent=\"$emit('toggleHidden')\"\n        >\n          <icon-eye-off htmlClass=\"w-5 h-5 cursor-pointer\" />\n        </span>\n        <span title=\"edit\" @click.prevent=\"$emit('enableEdit')\">\n          <icon-edit htmlClass=\"w-5 h-5 cursor-pointer\" />\n        </span>\n      </div>\n    </div>\n  </sn-card>\n</template>\n\n<script>\nimport Menu from \"../icons/Menu\";\nimport Edit from \"../icons/Edit\";\nimport Eye from \"../icons/Eye\";\nimport EyeOff from \"../icons/EyeOff\";\nimport SNCard from \"./sn-card.vue\";\n\nexport default {\n  props: [\"isHidden\", \"entry\", \"icon\"],\n  data() {\n    return {};\n  },\n  components: {\n    \"icon-menu\": Menu,\n    \"icon-edit\": Edit,\n    \"icon-eye\": Eye,\n    \"icon-eye-off\": EyeOff,\n    \"sn-card\": SNCard,\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/shownotes/link-unfurling.vue",
    "content": "<template>\n  <sn-card>\n    <div class=\"flex items-center justify-between\">\n      <div class=\"flex items-center gap-3 text-gray-500\">\n        <icon-menu htmlClass=\"w-5 h-5 drag-handle\" />\n        <div>\n          <div class=\"flex items-center gap-1\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              class=\"h-4 w-4 animate-spin\"\n              viewBox=\"0 0 20 20\"\n              fill=\"currentColor\"\n            >\n              <path\n                fill-rule=\"evenodd\"\n                d=\"M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z\"\n                clip-rule=\"evenodd\"\n                class=\"origin-center\"\n                transform=\"scale(1,-1)\"\n              />\n            </svg>\n            <div\n              class=\"w-[300px] h-4 rounded-md bg-gray-300 animate-pulse\"\n            ></div>\n          </div>\n        </div>\n      </div>\n      <div class=\"flex items-center gap-2.5 text-gray-500\">\n        <icon-eye htmlClass=\"w-5 h-5\" />\n        <icon-edit htmlClass=\"w-5 h-5\" />\n      </div>\n    </div>\n  </sn-card>\n</template>\n\n<script>\nimport SNCard from \"./sn-card.vue\";\nimport Menu from \"../icons/Menu\";\nimport Eye from \"../icons/Eye\";\nimport Edit from \"../icons/Edit\";\n\nexport default {\n  components: {\n    \"icon-menu\": Menu,\n    \"icon-edit\": Edit,\n    \"icon-eye\": Eye,\n    \"sn-card\": SNCard,\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/shownotes/link.vue",
    "content": "<template>\n  <div>\n    <link-unfurling v-if=\"entry.state == 'unfurling'\" />\n    <link-compact\n      v-else-if=\"!edit\"\n      v-bind:entry=\"entry\"\n      :icon=\"icon\"\n      :is-hidden=\"isHidden\"\n      v-on:toggleHidden=\"toggleHide()\"\n      v-on:enableEdit=\"edit = true\"\n    />\n    <!-- form -->\n    <sn-card v-else>\n      <div v-if=\"entry.image\">\n        <label class=\"block text-sm font-medium text-gray-700\">\n          Thumbnail\n        </label>\n        <div class=\"mt-1 flex\">\n          <img class=\"max-h-48 rounded\" :src=\"entry.image\" />\n        </div>\n\n        <div class=\"h-6\"></div>\n      </div>\n\n      <div class=\"\">\n        <label for=\"url\" class=\"block text-sm font-medium text-gray-700\">\n          URL\n        </label>\n        <div class=\"mt-1\">\n          <input\n            @keydown.enter.prevent=\"save()\"\n            @keydown.esc=\"edit = false\"\n            v-model=\"entry.url\"\n            type=\"text\"\n            name=\"url\"\n            id=\"url\"\n            placeholder=\"URL\"\n            class=\"\n              shadow-sm\n              focus:ring-blue-500 focus:border-blue-500\n              block\n              w-full\n              sm:text-sm\n              border border-gray-300\n              rounded-md\n            \"\n          />\n\n          <suggestion\n            v-if=\"\n              entry.unfurl_data &&\n              entry.unfurl_data.url &&\n              entry.unfurl_data.url != entry.url\n            \"\n            :title=\"entry.unfurl_data.url\"\n            @accept=\"entry.url = entry.unfurl_data.url\"\n          ></suggestion>\n        </div>\n      </div>\n\n      <div class=\"mt-6\">\n        <label for=\"sitename\" class=\"block text-sm font-medium text-gray-700\">\n          Site Name\n        </label>\n        <div class=\"mt-1\">\n          <input\n            @keydown.enter.prevent=\"save()\"\n            @keydown.esc=\"edit = false\"\n            v-model=\"entry.site_name\"\n            type=\"text\"\n            name=\"sitename\"\n            id=\"sitename\"\n            class=\"\n              shadow-sm\n              focus:ring-blue-500 focus:border-blue-500\n              block\n              w-full\n              sm:text-sm\n              border border-gray-300\n              rounded-md\n            \"\n          />\n\n          <suggestion\n            v-if=\"\n              entry.unfurl_data &&\n              entry.unfurl_data.site_name &&\n              entry.unfurl_data.site_name != entry.site_name\n            \"\n            :title=\"entry.unfurl_data.site_name\"\n            @accept=\"entry.site_name = entry.unfurl_data.site_name\"\n          ></suggestion>\n        </div>\n      </div>\n\n      <div class=\"mt-6\">\n        <label for=\"title\" class=\"block text-sm font-medium text-gray-700\">\n          Title\n        </label>\n        <div class=\"mt-1\">\n          <input\n            @keydown.enter.prevent=\"save()\"\n            @keydown.esc=\"edit = false\"\n            v-model=\"entry.title\"\n            type=\"text\"\n            name=\"title\"\n            id=\"title\"\n            class=\"\n              shadow-sm\n              focus:ring-blue-500 focus:border-blue-500\n              block\n              w-full\n              sm:text-sm\n              border border-gray-300\n              rounded-md\n            \"\n          />\n\n          <suggestion\n            v-if=\"\n              entry.unfurl_data &&\n              entry.unfurl_data.title &&\n              entry.unfurl_data.title != entry.title\n            \"\n            :title=\"entry.unfurl_data.title\"\n            @accept=\"entry.title = entry.unfurl_data.title\"\n          ></suggestion>\n        </div>\n      </div>\n\n      <div class=\"mt-6\">\n        <label\n          for=\"description\"\n          class=\"block text-sm font-medium text-gray-700\"\n        >\n          Description\n        </label>\n        <div class=\"mt-1\">\n          <textarea\n            v-model=\"entry.description\"\n            id=\"description\"\n            name=\"description\"\n            rows=\"3\"\n            class=\"\n              shadow-sm\n              focus:ring-blue-500 focus:border-blue-500\n              block\n              w-full\n              sm:text-sm\n              border border-gray-300\n              rounded-md\n            \"\n          />\n\n          <suggestion\n            v-if=\"\n              entry.unfurl_data &&\n              entry.unfurl_data.description &&\n              entry.unfurl_data.description != entry.description\n            \"\n            :title=\"entry.unfurl_data.description\"\n            @accept=\"entry.description = entry.unfurl_data.description\"\n          ></suggestion>\n        </div>\n      </div>\n\n      <div class=\"h-8 w-full border-b border-gray-300\"></div>\n\n      <div class=\"pt-5\">\n        <div class=\"flex justify-between\">\n          <div>\n            <sn-button type=\"danger\" :onClick=\"deleteEntry\"\n              >Delete Entry</sn-button\n            >\n          </div>\n          <div>\n            <div class=\"flex justify-end\">\n              <sn-button\n                :onClick=\"\n                  () => {\n                    edit = false;\n                  }\n                \"\n                >Cancel</sn-button\n              >\n              <sn-button :onClick=\"unfurl\" htmlClass=\"ml-3\"\n                >Autofill Metadata</sn-button\n              >\n              <sn-button :onClick=\"save\" type=\"primary\" htmlClass=\"ml-3\"\n                >Save</sn-button\n              >\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div\n        v-if=\"entry.state == 'failed'\"\n        class=\"h-6 w-full border-b border-gray-300\"\n      ></div>\n\n      <div\n        v-if=\"entry.state == 'failed'\"\n        class=\"mt-6 bg-red-50 border-l-4 border-red-400 p-4\"\n      >\n        <div class=\"flex\">\n          <div class=\"flex-shrink-0\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              class=\"h-5 w-5 text-red-400\"\n              viewBox=\"0 0 20 20\"\n              fill=\"currentColor\"\n            >\n              <path\n                fill-rule=\"evenodd\"\n                d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z\"\n                clip-rule=\"evenodd\"\n              />\n            </svg>\n          </div>\n          <div class=\"ml-3\">\n            <div class=\"text-sm text-red-700\">\n              <p class=\"font-bold\">\n                Unable to access URL:\n                <a :href=\"entry.original_url\" target=\"_blank\">{{\n                  entry.original_url\n                }}</a>\n              </p>\n\n              <p v-if=\"error_message\">\n                {{ error_message }}\n              </p>\n\n              <div v-if=\"trace_locations && trace_locations.length > 0\">\n                <strong>Location Trace:</strong>\n\n                <ul>\n                  <li\n                    v-for=\"(location, index) in trace_locations\"\n                    :key=\"location\"\n                  >\n                    {{ index }}: {{ location }}\n                  </li>\n                </ul>\n              </div>\n            </div>\n\n            <div class=\"mt-6\">\n              <label\n                for=\"original_url\"\n                class=\"block text-sm font-medium text-gray-700\"\n              >\n                Original URL\n              </label>\n              <div class=\"mt-1\">\n                <input\n                  @keydown.enter.prevent=\"saveOriginalUrl()\"\n                  @keydown.esc=\"edit = false\"\n                  v-model=\"entry.original_url\"\n                  type=\"text\"\n                  name=\"original_url\"\n                  id=\"original_url\"\n                  class=\"\n                    shadow-sm\n                    focus:ring-blue-500 focus:border-blue-500\n                    block\n                    w-full\n                    sm:text-sm\n                    border-gray-300\n                    rounded-md\n                  \"\n                />\n              </div>\n            </div>\n\n            <div class=\"mt-6 flex justify-end\">\n              <sn-button :onClick=\"saveOriginalUrl\">\n                Save and Unfurl\n              </sn-button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </sn-card>\n  </div>\n</template>\n\n<script>\nimport CheveronDown from \"../icons/CheveronDown\";\nimport CheveronUp from \"../icons/CheveronUp\";\nimport Menu from \"../icons/Menu\";\nimport Refresh from \"../icons/Refresh\";\nimport Edit from \"../icons/Edit\";\nimport Image from \"../icons/Image\";\nimport Link from \"../icons/Link\";\nimport Type from \"../icons/Type\";\nimport Eye from \"../icons/Eye\";\nimport EyeOff from \"../icons/EyeOff\";\n\nimport suggestion from \"./suggestion\";\n\nimport SNButton from \"./sn-button.vue\";\nimport SNCard from \"./sn-card.vue\";\nimport LinkCompact from \"./link-compact.vue\";\nimport LinkUnfurling from \"./link-unfurling.vue\";\n\nexport default {\n  props: [\"entry\"],\n  data() {\n    return {\n      edit: false,\n      error_message: \"\",\n      trace_locations: [],\n    };\n  },\n  components: {\n    \"icon-cheveron-down\": CheveronDown,\n    \"icon-cheveron-up\": CheveronUp,\n    \"icon-menu\": Menu,\n    \"icon-refresh\": Refresh,\n    \"icon-edit\": Edit,\n    \"icon-image\": Image,\n    \"icon-type\": Type,\n    \"icon-link\": Link,\n    \"icon-eye\": Eye,\n    \"icon-eye-off\": EyeOff,\n    suggestion: suggestion,\n    \"sn-button\": SNButton,\n    \"sn-card\": SNCard,\n    \"link-compact\": LinkCompact,\n    \"link-unfurling\": LinkUnfurling,\n  },\n  computed: {\n    icon: function () {\n      if (this.entry.icon && this.entry.icon[0] == \"/\") {\n        return this.entry.site_url + this.entry.icon;\n      } else {\n        return this.entry.icon;\n      }\n    },\n    isHidden: function () {\n      return this.entry.hidden === \"1\";\n    },\n  },\n  methods: {\n    unfurl: function () {\n      this.entry.state = \"unfurling\";\n      this.error_message = \"\";\n      this.trace_locations = [];\n\n      jQuery\n        .post(\n          podlove_vue.rest_url +\n            \"podlove/v1/shownotes/\" +\n            this.entry.id +\n            \"/unfurl\"\n        )\n        .done((result) => {\n          this.$parent.$emit(\"update:entry\", result);\n        })\n        .fail(({ responseJSON }) => {\n          if (!this.entry.url) {\n            this.entry.url = this.entry.original_url;\n          }\n\n          this.entry.state = \"failed\";\n          this.error_message =\n            \"[HTTP \" +\n            responseJSON.data.status +\n            \"] \" +\n            responseJSON.code +\n            \": \" +\n            responseJSON.message;\n          this.trace_locations = responseJSON.data.locations;\n          console.error(\"could not unfurl entry:\", responseJSON.message);\n        });\n    },\n    save: function () {\n      this.edit = false;\n\n      this.$parent.$emit(\"update:entry\", this.entry);\n\n      let payload = {};\n\n      payload.url = this.entry.url;\n      payload.title = this.entry.title;\n      payload.description = this.entry.description;\n\n      jQuery\n        .post(\n          podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          payload\n        )\n        .done((result) => {})\n        .fail(({ responseJSON }) => {\n          console.error(\"could not save entry:\", responseJSON.message);\n        });\n    },\n    saveOriginalUrl: function () {\n      this.$parent.$emit(\"update:entry\", this.entry);\n\n      let payload = {};\n\n      payload.original_url = this.entry.original_url;\n      payload.url = this.entry.url;\n      payload.title = this.entry.title;\n      payload.description = this.entry.description;\n\n      jQuery\n        .post(\n          podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          payload\n        )\n        .done((result) => {\n          this.unfurl();\n        })\n        .fail(({ responseJSON }) => {\n          console.error(\"could not save entry:\", responseJSON.message);\n        });\n    },\n    toggleHide: function () {\n      this.$parent.$emit(\"update:entry\", this.entry);\n\n      this.entry.hidden = this.isHidden ? \"0\" : \"1\";\n\n      let payload = { hidden: this.entry.hidden };\n\n      jQuery\n        .post(\n          podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          payload\n        )\n        .done((result) => {})\n        .fail(({ responseJSON }) => {\n          console.error(\"could not save entry:\", responseJSON.message);\n        });\n    },\n    deleteEntry: function () {\n      this.$parent.$emit(\"delete:entry\", this.entry);\n\n      jQuery\n        .ajax({\n          url: podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          method: \"DELETE\",\n          dataType: \"json\",\n        })\n        .done((result) => {})\n        .fail(({ responseJSON }) => {\n          console.error(\"could not delete entry:\", responseJSON.message);\n        });\n    },\n  },\n  mounted: function () {\n    if (!this.entry.state) {\n      this.unfurl();\n    }\n  },\n};\n</script>\n\n<style lang=\"css\">\n.unfurl-error-title {\n  font-weight: bold;\n  font-size: 13px;\n}\n\n.unfurl-error-message {\n  color: #dc3232;\n}\n\n.unfurl-error-location-trace,\n.unfurl-error-message {\n  margin-top: 9px;\n}\n\n.unfurl-error-location-trace li,\n.unfurl-error-message {\n  font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier,\n    monospace;\n}\n\n.unfurl-error-location-trace ul,\n.unfurl-error-location-trace li {\n  margin: 0;\n}\n\n.e-inline-edit {\n  text-decoration: underline;\n  cursor: pointer;\n}\n\n.footer-separator {\n  border-top: 1px solid #999;\n  margin: 12px 0 12px 0;\n}\n\n.edit-section-footer.failed {\n  background-color: rgb(254, 242, 242);\n  min-height: initial;\n}\n\n.sortable-chosen.p-card {\n  background: #dde1ec;\n  border-color: black;\n}\n\n.sortable-chosen .p-entry-content .p-entry-thumbnail,\n.sortable-chosen .p-entry-content .p-entry-description,\n.sortable-chosen .p-entry-content .p-entry-url-url {\n  display: none !important;\n}\n</style>\n"
  },
  {
    "path": "js/src/components/shownotes/sn-button.vue",
    "content": "<template>\n  <button type=\"button\" :class=\"htmlClasses\" @click.prevent=\"onClick\">\n    <slot></slot>\n  </button>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n      default: \"\",\n    },\n    onClick: {\n      type: Function,\n      required: true,\n    },\n    type: {\n      type: String,\n      required: false,\n      default: \"secondary\",\n    },\n  },\n  computed: {\n    htmlClasses: function () {\n      const defaultClasses = `bg-white\n      py-2\n      px-4\n      border\n      rounded-md\n      shadow-sm\n      text-sm\n      font-medium\n      focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\n      cursor-pointer`;\n\n      let typeClasses = \"\";\n\n      switch (this.type) {\n        case \"primary\":\n          typeClasses =\n            \"text-white border-gray-300 bg-blue-600 hover:bg-blue-700\";\n          break;\n        case \"secondary\":\n          typeClasses =\n            \"bg-white border-gray-300 hover:bg-gray-50 text-gray-700\";\n          break;\n        case \"danger\":\n          typeClasses =\n            \"border border-transparent text-red-700 bg-red-50 hover:bg-red-200 focus:ring-red-500\";\n          break;\n        default:\n          break;\n      }\n      return `${defaultClasses} ${typeClasses} ${this.htmlClass}`;\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/shownotes/sn-card.vue",
    "content": "<template>\n  <div :class=\"htmlClasses\">\n    <slot></slot>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    htmlClass: {\n      type: String,\n      required: false,\n    },\n  },\n  computed: {\n    htmlClasses: function () {\n      const defaultClasses = `\n            shadow\n            border border-1 border-gray-300\n            px-3\n            py-2.5\n            mx-5\n            my-3\n            max-w-3xl\n            rounded\n            bg-white`;\n\n      return `${defaultClasses} ${this.htmlClass}`;\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/shownotes/suggestion.vue",
    "content": "<template>\n<div class=\"e-suggestion\">\n  <div class=\"e-suggestion-wrapper\">\n    <div class=\"e-suggestion-content\">\n      <span>Suggestion</span>\n      <span>{{ title }}</span>            \n    </div>\n    <div class=\"e-suggestion-action\">\n      <div class=\"e-suggestion-action-wrapper\" @click.prevent=\"$emit('accept')\">\n        <span>ok</span>\n        <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-check\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>\n      </div>\n    </div>\n  </div>\n</div>\n</template>\n\n<script>\nexport default {\n  props: [\"title\"]\n};\n</script>\n\n<style>\n.e-suggestion-wrapper {\n  display: flex;\n  justify-content: space-between;\n  background-color: #fffff0;\n  border: 1px solid #faf089;\n  color: #744210;\n  padding: 8px;\n  margin-top: 2px;\n}\n\n.e-suggestion-content {\n  display: flex;\n  flex-direction: column;\n}\n\n.e-suggestion-content span:last-child {\n  font-weight: bold;\n}\n\n.e-suggestion-action-wrapper {\n  display: flex;\n  align-items: center;\n  cursor: pointer;\n}\n\n.e-suggestion-action-wrapper span {\n  margin-right: 4px;\n}\n</style>\n"
  },
  {
    "path": "js/src/components/shownotes/topic.vue",
    "content": "<template>\n  <div>\n    <div\n      v-if=\"!edit\"\n      class=\"flex items-center px-3 py-2.5 mx-5 mt-8 mb-0 max-w-3xl gap-3\"\n    >\n      <div class=\"text-gray-500 cursor-move\">\n        <icon-menu htmlClass=\"w-5 h-5 drag-handle\" />\n      </div>\n      <div class=\"flex-grow font-bold text-base border-b-2 border-gray-800\">\n        {{ entry.title }}\n      </div>\n      <div class=\"text-gray-500 cursor-pointer\" @click.prevent=\"edit = true\">\n        <icon-edit htmlClass=\"w-5 h-5\" />\n      </div>\n    </div>\n    <!-- Topic edit -->\n    <sn-card v-else>\n      <div class=\"flex items-center justify-between\">\n        <div class=\"w-full\">\n          <label\n            for=\"topic_title\"\n            class=\"block text-sm font-medium text-gray-700\"\n          >\n            Title\n          </label>\n          <div class=\"mt-1\">\n            <input\n              @keydown.enter.prevent=\"save()\"\n              @keydown.esc=\"edit = false\"\n              v-model=\"entry.title\"\n              type=\"text\"\n              name=\"topic_title\"\n              id=\"topic_title\"\n              class=\"\n                shadow-sm\n                focus:ring-blue-500 focus:border-blue-500\n                block\n                w-full\n                sm:text-sm\n                border-gray-300\n                rounded-md\n              \"\n            />\n          </div>\n        </div>\n      </div>\n\n      <div class=\"h-8 w-full border-b border-gray-300\"></div>\n\n      <div class=\"pt-5\">\n        <div class=\"flex justify-between\">\n          <div>\n            <sn-button type=\"danger\" :onClick=\"deleteEntry\"\n              >Delete Topic</sn-button\n            >\n          </div>\n          <div>\n            <div class=\"flex justify-end\">\n              <sn-button\n                :onClick=\"\n                  () => {\n                    edit = false;\n                  }\n                \"\n                >Cancel</sn-button\n              >\n\n              <sn-button type=\"primary\" :onClick=\"save\" htmlClass=\"ml-3\"\n                >Save</sn-button\n              >\n            </div>\n          </div>\n        </div>\n      </div>\n    </sn-card>\n  </div>\n</template>\n\n<script>\nimport Menu from \"../icons/Menu\";\nimport Edit from \"../icons/Edit\";\nimport Type from \"../icons/Type\";\nimport SNButton from \"./sn-button.vue\";\nimport SNCard from \"./sn-card.vue\";\n\nexport default {\n  props: [\"entry\"],\n  data() {\n    return {\n      edit: false,\n    };\n  },\n  components: {\n    \"icon-menu\": Menu,\n    \"icon-edit\": Edit,\n    \"icon-type\": Type,\n    \"sn-button\": SNButton,\n    \"sn-card\": SNCard,\n  },\n  methods: {\n    save: function () {\n      this.edit = false;\n\n      this.$parent.$emit(\"update:entry\", this.entry);\n\n      let payload = { title: this.entry.title };\n\n      jQuery\n        .post(\n          podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          payload\n        )\n        .done((result) => {})\n        .fail(({ responseJSON }) => {\n          console.error(\"could not delete entry:\", responseJSON.message);\n        });\n    },\n    deleteEntry: function () {\n      this.$parent.$emit(\"delete:entry\", this.entry);\n\n      jQuery\n        .ajax({\n          url: podlove_vue.rest_url + \"podlove/v1/shownotes/\" + this.entry.id,\n          method: \"DELETE\",\n          dataType: \"json\",\n        })\n        .done((result) => {})\n        .fail(({ responseJSON }) => {\n          console.error(\"could not delete entry:\", responseJSON.message);\n        });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "js/src/components/temp.xml",
    "content": "<!-- Topic -->\n<div class=\"flex items-center px-3 py-2.5 mx-5 mt-8 mb-0 max-w-3xl gap-3\">\n    <div class=\"text-gray-500\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n            <path fill-rule=\"evenodd\" d=\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\" clip-rule=\"evenodd\" />\n        </svg>\n    </div>\n    <div class=\"flex-grow font-bold text-md border-b-2 border-gray-800\">Verbesserungen bei Podlove</div>\n    <div class=\"text-gray-500\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"w-5 h-5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n            <path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path>\n            <path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>\n        </svg>\n    </div>\n</div>\n\n<!-- compact -->\n<div class=\"shadow border border-1 border-gray-300 px-3 py-2.5 mx-5 my-3 max-w-3xl rounded bg-white\">\n    <div class=\"flex items-center justify-between\">\n        <div class=\"flex items-center gap-3 text-gray-500\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                <path fill-rule=\"evenodd\" d=\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\" clip-rule=\"evenodd\" />\n            </svg>\n            <div>\n                <div class=\"flex items-center gap-1\">\n                    <div>\n                        <img src=\"https://sendegate.de/uploads/default/optimized/2X/5/564ebd6511a3ba0ede755d80be348e540190132c_2_32x32.png\" width=\"16\" height=\"16\" />\n                    </div>\n                    <span class=\"text-sm text-gray-800 font-bold\">\n                        <a href=\"https://sendegate.de/t/rode-podcaster-usb-an-xlr-interface/12333\" target=\"_blank\">Rode Podcaster (USB) an XLR interface?</a>\n                    </span>\n                </div>\n            </div>\n        </div>\n        <div class=\"flex items-center gap-2.5 text-gray-500\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"w-5 h-5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path>\n                <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n            </svg>\n\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"w-5 h-5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path>\n                <path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>\n            </svg>\n        </div>\n    </div>\n</div>\n\n<!-- extended -->\n<div class=\"shadow border border-1 border-gray-300 px-3 py-2.5 mx-5 my-3 max-w-3xl rounded bg-white\">\n    <div class=\"flex items-center justify-between\">\n        <div class=\"flex items-center gap-3 text-gray-500\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                <path fill-rule=\"evenodd\" d=\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\" clip-rule=\"evenodd\" />\n            </svg>\n            <div>\n                <div class=\"flex items-center gap-1\">\n                    <div>\n                        <img src=\"https://sendegate.de/uploads/default/optimized/2X/5/564ebd6511a3ba0ede755d80be348e540190132c_2_32x32.png\" width=\"16\" height=\"16\" />\n                    </div>\n                    <span class=\"text-sm text-gray-800 font-bold\">\n                        <a href=\"https://sendegate.de/t/rode-podcaster-usb-an-xlr-interface/12333\" target=\"_blank\">Rode Podcaster (USB) an XLR interface?</a>\n                    </span>\n                </div>\n            </div>\n        </div>\n        <div class=\"flex items-center gap-2.5 text-gray-500\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"w-5 h-5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path>\n                <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n            </svg>\n\n            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"w-5 h-5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path>\n                <path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>\n            </svg>\n        </div>\n    </div>\n    <div class=\"text-sm mx-[3.125rem] text-gray-400\">\n        <div class=\"\">https://sendegate.de/t/rode-podcaster-usb-an-xlr-interface/12333</div>\n        <div class=\"mt-2.5 flex gap-2.5\">\n            <div class=\"w-36 flex-shrink-0\">\n                <img class=\"rounded\" src=\"http://localhost:10022/wp-content/uploads/2021/10/maxresdefault-3.jpg\" alt=\"\" />\n            </div>\n            <p class=\"line-clamp-4\">Ahoi, Ich habe gerade, ganz klassisch, das HMC-660 am Xenyx 302 im Einsatz. Aber auch noch ein Rode Podcaster rumliegen. Jetzt frage ich mich, ob und wie ich den Rode Podcaster an das Interface bekomme. Gibt es da Adapter/Kabel? Warum nicht das Rode ohne Interface? Ich möchte es nicht mehr missen, Monitor und Audio der Anderen getrennt regeln zu können. Außerdem finde ich die Kabelei unangenehm, wenn die Monitor-Kopfhörer direkt im Rode stecken müssen. Danke euch 🙂</p>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "js/src/lib/duration_errors.js",
    "content": "export default {\n    'LONGER_THAN_TOTAL': -1,\n    'TOTAL_UNKNOWN': -2,\n    'TOTAL_INVALID': -3\n}\n"
  },
  {
    "path": "js/src/lib/guid.js",
    "content": "export default function guid() {\n  function s4() {\n    return Math.floor((1 + Math.random()) * 0x10000)\n      .toString(16)\n      .substring(1);\n  }\n  return s4() + s4() + '-' + s4() + '-' + s4() + '-' +\n    s4() + '-' + s4() + s4() + s4();\n}\n"
  },
  {
    "path": "js/src/lib/timestamp.js",
    "content": "const npt = require('normalplaytime');\n\nexport default class Timestamp {\n    constructor(totalMs) {\n        this.totalMs = totalMs;\n    }\n\n    get totalSeconds() {\n        return Math.floor(this.totalMs / 1000);\n    }\n\n    get totalMinutes() {\n        return Math.floor(this.totalSeconds / 60);\n    }\n    \n    get totalHours() {\n        return Math.floor(this.totalMinutes / 60);\n    }\n    \n    get milliseconds() {\n        return this.totalMs % 1000;\n    }\n    \n    get seconds() {\n        return this.totalSeconds % 60;\n    }\n    \n    get minutes() {\n        return this.totalMinutes % 60;\n    }\n    \n    get hours() {\n        return this.totalHours % 60;\n    }\n\n    get pretty() {\n      return this.pad(this.totalHours) + \":\" + this.pad(this.minutes) + \":\" + this.pad(this.seconds) + \".\" + this.pad(this.milliseconds, \"000\");\n    }\n\n    get prettyShort() {\n        if (this.totalHours) {\n            return this.pad(this.totalHours) + \":\" + this.pad(this.minutes) + \":\" + this.pad(this.seconds);\n        } else {\n            return this.pad(this.minutes) + \":\" + this.pad(this.seconds);            \n        }\n    }\n    \n    pad(num, pad = \"00\") {\n        let str = \"\" + num;\n\n        if (str.length < pad.length) {\n            return pad.substring(0, pad.length - str.length) + str;\n        } else {\n            return num;\n        }\n    }\n\n    static fromString(t) {\n        let ms = 0;\n\n        if (t == parseInt(t, 10)) {\n            ms = parseInt(t, 10);\n        } else {\n            ms = npt.parse(t);\n        }\n\n        return new Timestamp(ms);            \n    }\n}\n"
  },
  {
    "path": "js/webpack.mix.js",
    "content": "let mix = require(\"laravel-mix\");\n\n/*\n |--------------------------------------------------------------------------\n | Mix Asset Management\n |--------------------------------------------------------------------------\n |\n | Mix provides a clean, fluent API for defining some Webpack build steps\n | for your Laravel application. By default, we are compiling the Sass\n | file for your application, as well as bundling up your JS files.\n |\n */\n\nmix.js('src/app.js', 'dist/').vue()\n  .combine(\n    [\n      \"node_modules/clipboard/dist/clipboard.min.js\",\n      \"admin/chosen/chosen.jquery.min.js\",\n      \"admin/chosen/chosenImage.jquery.js\",\n      \"src/admin/md5.js\",\n      \"src/admin/timeago.jquery.js\",\n      \"src/admin/jquery.count_characters.js\",\n      \"src/admin/podlove_data_table.js\",\n      \"src/admin/episode.js\",\n      \"src/admin/jobs.js\",\n      \"src/admin/dashboard_asset_validation.js\",\n      \"src/admin/dashboard_feed_validation.js\",\n      \"src/admin/episode_asset_settings.js\",\n      \"src/admin/license.js\",\n      \"src/admin/media.js\",\n      \"src/admin/protected_feed.js\",\n      \"src/admin/feed_settings.js\",\n      \"src/admin/post_title_autogenerate.js\",\n      \"src/admin.js\",\n    ],\n    \"dist/podlove-admin.js\"\n  )\n\n  .babel(\n    [\n      \"admin/dc.js\",\n      \"src/analytics/common.js\",\n      \"src/analytics/episode.js\",\n      \"src/analytics/totals.js\",\n    ],\n    \"dist/podcast-stats.js\"\n  );\n\n// Full API\n// mix.js(src, output);\n// mix.react(src, output); <-- Identical to mix.js(), but registers React Babel compilation.\n// mix.extract(vendorLibs);\n// mix.sass(src, output);\n// mix.standaloneSass('src', output); <-- Faster, but isolated from Webpack.\n// mix.fastSass('src', output); <-- Alias for mix.standaloneSass().\n// mix.less(src, output);\n// mix.stylus(src, output);\n// mix.browserSync('my-site.dev');\n// mix.combine(files, destination);\n// mix.babel(files, destination); <-- Identical to mix.combine(), but also includes Babel compilation.\n// mix.copy(from, to);\n// mix.copyDirectory(fromDir, toDir);\n// mix.minify(file);\n// mix.sourceMaps(); // Enable sourcemaps\n// mix.version(); // Enable versioning.\n// mix.disableNotifications();\n// mix.setPublicPath('path/to/public');\n// mix.setResourceRoot('prefix/for/resource/locators');\n// mix.autoload({}); <-- Will be passed to Webpack's ProvidePlugin.\n// mix.webpackConfig({}); <-- Override webpack.config.js, without editing the file directly.\n// mix.then(function () {}) <-- Will be triggered each time Webpack finishes building.\n// mix.options({\n//   extractVueStyles: false, // Extract .vue component styling to file, rather than inline.\n//   processCssUrls: true, // Process/optimize relative stylesheet url()'s. Set to false, if you don't want them touched.\n//   purifyCss: false, // Remove unused CSS selectors.\n//   uglify: {}, // Uglify-specific options. https://webpack.github.io/docs/list-of-plugins.html#uglifyjsplugin\n//   postCss: [] // Post-CSS options: https://github.com/postcss/postcss/blob/master/docs/plugins.md\n// });\n"
  },
  {
    "path": "lib/ajax/ajax.analytics_global_total_downloads_by_show.html.php",
    "content": "<?php if (count($downloads) === 0) {\n    echo 'no data';\n} else { ?>\n<table style=\"margin-left: 7px;\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <thead>\n        <tr>\n            <th style=\"text-align: left; padding: 0 0 0.25rem 0.25rem\"><?php _e('Show', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n            <th style=\"text-align: left; padding: 0 0 0.25rem 0.25rem\"><?php _e('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n        </tr>\n    </thead>\n    <tbody>\n        <?php $i = 1; ?>\n        <?php foreach ($downloads as $row) { ?>\n            <?php ++$i; ?>\n            <tr style=\"<?php echo ($i % 2 == 0) ? 'background-color: rgba(249, 250, 251, 1);' : ''; ?>\">\n                <td style=\"padding: 0.25rem 1rem 0.25rem 0.25rem;\"><?php echo $row['show_name'] ?? __('Without Assigned Show', 'podlove-podcasting-plugin-for-wordpress'); ?></td>\n                <td style=\"padding: 0.25rem 1rem 0.25rem 0.25rem;\"><?php echo number_format_i18n($row['downloads']); ?></td>\n            </tr>\n        <?php } ?>\n    </tbody>\n</table>\n<?php\n}\n"
  },
  {
    "path": "lib/ajax/ajax.php",
    "content": "<?php\n\nnamespace Podlove\\AJAX;\n\nuse League\\Csv\\Writer;\nuse Podlove\\Model;\nuse Podlove\\Modules\\Onboarding\\Onboarding;\n\nclass Ajax\n{\n    /**\n     * Conventions:\n     * - all actions must be prefixed with \"podlove-\"\n     * - hyphens in actions are substituted for underscores in methods.\n     */\n    public function __construct()\n    {\n        $actions = [\n            'get-new-guid',\n            'validate-url',\n            'update-asset-position',\n            'update-feed-position',\n            'podcast',\n            'hide-teaser',\n            'banner-hide',\n            'onboarding-acknowledge',\n            'get-license-url',\n            'get-license-name',\n            'get-license-parameters-from-url',\n            'analytics-downloads-per-day',\n            'analytics-episode-downloads-per-hour',\n            'analytics-total-downloads-per-day',\n            'analytics-episode-average-downloads-per-hour',\n            'analytics-settings-tiles-update',\n            'analytics-settings-avg-update',\n            'analytics-global-assets',\n            'analytics-global-clients',\n            'analytics-global-systems',\n            'analytics-global-sources',\n            'analytics-global-downloads-per-month',\n            'analytics-global-top-episodes',\n            'analytics-global-total-downloads',\n            'analytics-global-total-downloads-by-show',\n            'analytics-csv-episodes-table',\n            'admin-news',\n            'job-create',\n            'job-get',\n            'job-delete',\n            'jobs-get'\n        ];\n\n        // kickoff generic ajax methods\n        foreach ($actions as $action) {\n            add_action('wp_ajax_podlove-'.$action, [$this, str_replace('-', '_', $action)]);\n        }\n\n        // kickof specialized ajax controllers\n        TemplateController::init();\n        // TODO: remove once Dashboard Validation UI uses REST API\n        FileController::init();\n    }\n\n    public function job_create()\n    {\n        if (!current_user_can('administrator')) {\n            http_response_code(401);\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $job_name = filter_input(INPUT_POST, 'name');\n        $job_args = isset($_REQUEST['args']) && is_array($_REQUEST['args']) ? $_REQUEST['args'] : [];\n\n        // check class exists\n        if (!class_exists($job_name)) {\n            self::respond_with_json(['error' => 'job \"'.$job_name.'\" does not exist']);\n        }\n\n        // check that class is a job\n        if (!isset(class_uses($job_name)['Podlove\\Jobs\\JobTrait'])) {\n            self::respond_with_json(['error' => '\"'.$job_name.'\" is not a job']);\n        }\n\n        $job = \\Podlove\\Jobs\\CronJobRunner::create_job($job_name, $job_args);\n\n        if ($job) {\n            self::respond_with_json([\n                'job_id' => $job->get_job_id(),\n            ]);\n        } else {\n            self::respond_with_json(['error' => 'A job \"'.$job_name.'\" is already running']);\n        }\n    }\n\n    public function job_get()\n    {\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n        $job_id = filter_input(INPUT_GET, 'job_id');\n        $job = \\Podlove\\Model\\Job::find_by_id($job_id);\n\n        if (!$job) {\n            self::respond_with_json(['error' => 'no job with id \"'.$job_id.'\"']);\n        }\n\n        self::respond_with_json($job->to_array());\n    }\n\n    public function job_delete()\n    {\n        if (!current_user_can('administrator')) {\n            http_response_code(401);\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $job_id = filter_input(INPUT_GET, 'job_id');\n        $job = \\Podlove\\Model\\Job::find_by_id($job_id);\n\n        if (!$job) {\n            self::respond_with_json(['error' => 'no job with id \"'.$job_id.'\"']);\n        }\n\n        $job->delete();\n\n        self::respond_with_json(['status' => 'ok']);\n    }\n\n    public function jobs_get()\n    {\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n\n        $jobs = \\Podlove\\Model\\Job::all();\n        $jobs = array_map(function ($j) {\n            $job = $j->to_array();\n\n            $job_class = $job['class'];\n\n            if (!class_exists($job_class)) {\n                $job_class = str_replace('\\\\\\\\', '\\\\', $job_class);\n            }\n\n            $job['title'] = $job_class::title();\n            // $job['description'] = $job_class::description();\n            $job['mode'] = $job_class::mode(maybe_unserialize($job['args']));\n\n            if ($job['steps_total'] > 0) {\n                $steps_percent = floor(100 * ($job['steps_progress'] / $job['steps_total']));\n                $job['steps_percent'] = $steps_percent < 100 ? $steps_percent : 100;\n            } else {\n                $job['steps_percent'] = 0;\n            }\n\n            $job['active_run_time'] = round($job['active_run_time'], 2);\n\n            $job['created_relative'] = sprintf(__('%s ago'), human_time_diff(strtotime($job['created_at'])));\n            $job['created_at_timestamp'] = strtotime($job['created_at']);\n\n            if (!$job['wakeups'] || $job['created_at'] == $job['updated_at']) {\n                $job['last_progress'] = __('Never');\n            } else {\n                $seconds = time() - strtotime($job['updated_at']);\n                if ($seconds < 5) {\n                    $job['last_progress'] = __('just now');\n                } elseif ($seconds < 60) {\n                    $job['last_progress'] = sprintf(__('%s sec ago'), $seconds);\n                } else {\n                    $job['last_progress'] = sprintf(__('%s ago'), human_time_diff(strtotime($job['updated_at'])));\n                }\n            }\n\n            return $job;\n        }, $jobs);\n\n        self::respond_with_json($jobs);\n    }\n\n    public function admin_news()\n    {\n        require_once ABSPATH.'wp-admin/includes/dashboard.php';\n        \\Podlove\\Settings\\Dashboard\\News::content();\n        wp_die();\n    }\n\n    public function analytics_episode_average_downloads_per_hour()\n    {\n        global $wpdb;\n\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $downloads = $wpdb->get_col(\"\n\t\t\tSELECT\n\t\t\t\tmeta_value\n\t\t\tFROM\n\t\t\t\t{$wpdb->postmeta} pm\n\t\t\t\tJOIN {$wpdb->posts} p ON pm.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tpm.meta_key = '_podlove_eda_downloads'\n\t\t\t\tAND p.post_status IN ('publish', 'private')\n\t\t\tGROUP BY\n\t\t\t\tpm.post_id\n\t\t\");\n\n        $downloads = array_reduce($downloads, function ($agg, $item) {\n            $row = explode(',', $item);\n\n            // skip episodes with missing data, for example if released before tracking was started\n            if (array_sum(array_slice($row, 0, 24)) < 10) {\n                return $agg;\n            }\n\n            // skip young episodes\n            if (count($row) < \\Podlove\\Analytics\\EpisodeDownloadAverage::HOURS_TO_CALCULATE / 2) {\n                return $agg;\n            }\n\n            if (empty($agg)) {\n                $agg = $row;\n            } else {\n                for ($i = 0; $i < \\Podlove\\Analytics\\EpisodeDownloadAverage::HOURS_TO_CALCULATE; ++$i) {\n                    if (isset($row[$i])) {\n                        $agg['downloads'][$i] += $row[$i];\n                    }\n                }\n                ++$agg['rows'];\n            }\n\n            return $agg;\n        }, ['downloads' => array_fill(0, \\Podlove\\Analytics\\EpisodeDownloadAverage::HOURS_TO_CALCULATE, 0), 'rows' => 0]);\n\n        $downloads = array_map(function ($item) use ($downloads) {\n            if ($downloads['rows'] > 0) {\n                return round($item / $downloads['rows']);\n            }\n\n            return 0;\n        }, $downloads['downloads']);\n\n        $csv = '\"downloads\",\"hoursSinceRelease\"'.\"\\n\";\n        foreach ($downloads as $key => $value) {\n            $csv .= \"{$value},{$key}\\n\";\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n        echo $csv;\n        ob_end_flush();\n\n        exit;\n    }\n\n    public function analytics_downloads_per_day()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        $episode_id = isset($_GET['episode']) ? (int) $_GET['episode'] : 0;\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        echo $cache->cache_for('podlove_analytics_dpd_'.$episode_id, function () use ($episode_id) {\n            global $wpdb;\n\n            $episode_cond = '';\n            if ($episode_id) {\n                $episode_cond = \" AND episode_id = {$episode_id}\";\n            }\n\n            $sql = 'SELECT COUNT(*) downloads, post_title, access_date, episode_id, post_id\n\t\t\t\t\tFROM (\n\t\t\t\t\t\tSELECT\n\t\t\t\t\t\t\tmedia_file_id, accessed_at, DATE(accessed_at) access_date, episode_id\n\t\t\t\t\t\tFROM\n\t\t\t\t\t\t\t'.Model\\DownloadIntent::table_name().' di\n\t\t\t\t\t\t\tINNER JOIN '.Model\\MediaFile::table_name().\" mf ON mf.id = di.media_file_id\n\t\t\t\t\t\tWHERE 1 = 1 {$episode_cond}\n\t\t\t\t\t\tGROUP BY media_file_id, request_id, access_date\n\t\t\t\t\t) di\n                    INNER JOIN \".Model\\Episode::table_name().\" e ON episode_id = e.id\n\t\t\t\t\tINNER JOIN {$wpdb->posts} p ON e.post_id = p.ID\n\t\t\t\t\tWHERE accessed_at > p.post_date_gmt\n\t\t\t\t\tGROUP BY access_date, episode_id\";\n\n            $results = $wpdb->get_results($sql, ARRAY_N);\n\n            $release_date = min(array_column($results, 2));\n\n            $csv = '\"downloads\",\"title\",\"date\",\"episode_id\",\"post_id\",\"days\"'.\"\\n\";\n            foreach ($results as $row) {\n                $row[1] = '\"'.str_replace('\"', '\"\"', $row[1]).'\"'; // quote & escape title\n                $row[] = date_diff(date_create($release_date), date_create($row[2]))->format('%a');\n                $csv .= implode(',', $row).\"\\n\";\n            }\n\n            return $csv;\n        }, 3600);\n\n        exit;\n    }\n\n    public function analytics_episode_downloads_per_hour()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $episode_id = isset($_GET['episode']) ? (int) $_GET['episode'] : 0;\n        $cache_key = 'podlove_analytics_dphx_'.$episode_id;\n\n        $locale = get_locale();\n        $known_langs = ['de', 'en', 'es', 'fr', 'ja', 'pt-BR', 'ru', 'zh-CN'];\n\n        $lang = 'en';\n        foreach ($known_langs as $l) {\n            if (stristr($locale, $l) !== false) {\n                $lang = $l;\n\n                break;\n            }\n        }\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        $content = $cache->cache_for($cache_key, function () use ($episode_id, $lang) {\n            global $wpdb;\n\n            $sql = 'SELECT\n\t\t\t\t\t\tCOUNT(*) downloads,\n\t\t\t\t\t\tUNIX_TIMESTAMP(accessed_at) AS access_date,\n\t\t\t\t\t\thours_since_release,\n\t\t\t\t\t\tmf.episode_asset_id asset_id,\n\t\t\t\t\t\tclient_name,\n\t\t\t\t\t\tos_name,\n\t\t\t\t\t\tsource,\n\t\t\t\t\t\tcontext,\n\t\t\t\t\t\tgeo.type as t1,\n\t\t\t\t\t\tgeoname.name as tn1,\n\t\t\t\t\t\tgeo_p.type as t2,\n\t\t\t\t\t\tgeoname_p.name as tn2,\n\t\t\t\t\t\tgeo_pp.type as t3,\n\t\t\t\t\t\tgeoname_pp.name as tn3\n\t\t\t\t\tFROM\n\t\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\t\t\tINNER JOIN '.Model\\MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\t\t\t\tLEFT JOIN '.Model\\UserAgent::table_name().' ua ON ua.id = di.user_agent_id\n\n\t\t\t\t\t\tLEFT JOIN '.Model\\GeoArea::table_name().' geo ON geo.id = di.`geo_area_id`\n\t\t\t\t\t\tLEFT JOIN '.Model\\GeoArea::table_name().' geo_p ON geo_p.id = geo.parent_id\n\t\t\t\t\t\tLEFT JOIN '.Model\\GeoArea::table_name().' geo_pp ON geo_pp.id = geo_p.parent_id\n\t\t\t\t\t\tLEFT JOIN '.Model\\GeoAreaName::table_name().\" geoname ON geoname.area_id = geo.`id` and geoname.language = \\\"{$lang}\\\"\n\t\t\t\t\t\tLEFT JOIN \".Model\\GeoAreaName::table_name().\" geoname_p ON geoname_p.area_id = geo_p.`id` and geoname_p.language = \\\"{$lang}\\\"\n\t\t\t\t\t\tLEFT JOIN \".Model\\GeoAreaName::table_name().\" geoname_pp ON geoname_pp.area_id = geo_pp.`id` and geoname_pp.language = \\\"{$lang}\\\"\n\n\t\t\t\t\t\tWHERE episode_id = {$episode_id}\n\t\t\t\t\t\tGROUP BY hours_since_release, asset_id, client_name, os_name, source, context\";\n\n            $results = $wpdb->get_results($sql, ARRAY_N);\n\n            $csv = '\"downloads\",\"date\",\"hours_since_release\",\"asset_id\",\"client\",\"system\",\"source\",\"context\",\"geo\"'.\"\\n\";\n            foreach ($results as $row) {\n                $geos = [\n                    ['type' => $row[8], 'name' => $row[9]],\n                    ['type' => $row[10], 'name' => $row[11]],\n                    ['type' => $row[12], 'name' => $row[13]],\n                ];\n\n                unset($row[8], $row[9], $row[10], $row[11], $row[12], $row[13]);\n\n                $geo = array_filter($geos, function ($g) {\n                    return $g['type'] == 'country';\n                });\n\n                if (count($geo)) {\n                    $row[8] = reset($geo)['name'];\n                } else {\n                    $row[8] = '';\n                }\n\n                $row[4] = '\"'.$row[4].'\"';\n                $row[5] = '\"'.$row[5].'\"';\n                $row[8] = '\"'.$row[8].'\"';\n                $csv .= implode(',', $row).\"\\n\";\n            }\n\n            return $csv;\n        }, 3600);\n\n        $etag = md5($content);\n\n        header(\"Etag: {$etag}\");\n        header('Last-Modified: '.gmdate('D, d M Y H:i:s', $cache->expiration_for($cache_key)).' GMT');\n\n        $etagHeader = (isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH']) : false);\n\n        if ($etagHeader == $etag) {\n            header('HTTP/1.1 304 Not Modified');\n\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n        echo $content;\n        ob_end_flush();\n\n        exit;\n    }\n\n    public function analytics_total_downloads_per_day()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $cache_key = 'podlove_analytics_tdphx';\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        $content = $cache->cache_for($cache_key, function () {\n            global $wpdb;\n\n            $sql = \"SELECT\n\t\t\t    COUNT(*) downloads,\n\t\t\t    UNIX_TIMESTAMP(accessed_at) AS access_date,\n\t\t\t    DATE_FORMAT(accessed_at, '%Y-%m-%d') AS date_day,\n\t\t\t    mf.episode_id\n\t\t\tFROM\n\t\t\t    \".Model\\DownloadIntentClean::table_name().'  di\n\t\t\t    INNER JOIN '.Model\\MediaFile::table_name().\" mf ON mf.id = di.media_file_id\n\t\t\tWHERE accessed_at >= STR_TO_DATE('\".date('Y-m-d', strtotime('-28 days')).\"','%Y-%m-%d')\n\t\t\tGROUP BY date_day, episode_id\n\t\t\t\";\n\n            $results = $wpdb->get_results($sql, ARRAY_N);\n\n            $csv = '\"downloads\",\"date\",\"day\",\"episode_id\"'.\"\\n\";\n            foreach ($results as $row) {\n                $csv .= implode(',', $row).\"\\n\";\n            }\n\n            return $csv;\n        }, 3600);\n\n        $etag = md5($content);\n\n        header(\"Etag: {$etag}\");\n        header('Last-Modified: '.gmdate('D, d M Y H:i:s', $cache->expiration_for($cache_key)).' GMT');\n\n        $etagHeader = (isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH']) : false);\n\n        if ($etagHeader == $etag) {\n            header('HTTP/1.1 304 Not Modified');\n\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n        echo $content;\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_settings_tiles_update()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $tile_id = $_GET['tile_id'];\n        $checked = isset($_GET['checked']) && $_GET['checked'] === 'checked';\n\n        $option = get_option('podlove_analytics_tiles', []);\n        $option[$tile_id] = $checked;\n        update_option('podlove_analytics_tiles', $option);\n    }\n\n    public static function analytics_settings_avg_update()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $checked = isset($_GET['checked']) && $_GET['checked'] === 'checked';\n        update_option('podlove_analytics_compare_avg', $checked);\n    }\n\n    public static function analytics_csv_episodes_table()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        $data = \\Podlove\\Downloads_List_Data::get_data('post_date', 'asc');\n\n        $writer = Writer::createFromFileObject(new \\SplTempFileObject()); // the CSV file will be created into a temporary File\n\n        $headers = array_keys($data[0]);\n        $writer->insertOne($headers);\n\n        $writer->insertAll($data);\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/csv');\n        header('Content-Disposition: attachment; filename=podlove-episode-downloads.csv');\n        echo $writer;\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function respond_with_json($result)\n    {\n        header('Cache-Control: no-cache, must-revalidate');\n        header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n        header('Content-type: application/json');\n        echo wp_json_encode($result);\n\n        exit;\n    }\n\n    // SELECT\n    //     count(id) downloads,\n    //     source\n    // FROM\n    //     wp_podlove_downloadintentclean\n    // GROUP BY\n    //     source\n    // ORDER BY\n    //     downloads DESC;\n\n    // SELECT\n    //     count(id) downloads,\n    //     CONCAT(source, \"/\", context)\n    // FROM\n    //     wp_podlove_downloadintentclean\n    // GROUP BY\n    //     source,\n    //     context\n    // ORDER BY\n    //     downloads DESC;\n\n    // SELECT\n    //     count(di.id) downloads,\n    //     t.name\n    // FROM\n    //     wp_podlove_downloadintentclean di\n    //     JOIN `wp_podlove_mediafile` f ON f.id = di.`media_file_id`\n    //     JOIN `wp_podlove_episodeasset` a ON a.id = f.`episode_asset_id`\n    //     JOIN `wp_podlove_filetype` t ON t.id = a.`file_type_id`\n    // GROUP BY\n    //     t.id\n    // ORDER BY\n    //     downloads DESC;\n\n    public static function analytics_global_assets()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_assets'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_results('\n\t\t\t\tSELECT\n\t\t\t\t\tcount(di.id) downloads, t.name\n\t\t\t\tFROM\n\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\t\tJOIN `'.Model\\MediaFile::table_name().'` f ON f.id = di.`media_file_id`\n\t\t\t\t\tJOIN `'.Model\\EpisodeAsset::table_name().'` a ON a.id = f.`episode_asset_id`\n\t\t\t\t\tJOIN `'.Model\\FileType::table_name().'` t ON t.id = a.`file_type_id`\n\t\t\t\tWHERE 1 = 1 AND '.self::analytics_date_condition().'\n\t\t\t\tGROUP BY\n\t\t\t\t\tt.id\n\t\t\t\tORDER BY\n\t\t\t\t\tdownloads DESC;\n\t\t\t', ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'asset']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_global_clients()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_clients'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_results('\n\t\t\t\tSELECT\n\t\t\t\t\t\tcount(di.id) downloads,\n\t\t\t\t\t\tua.client_name\n\t\t\t\tFROM\n\t\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\t\t\tJOIN `'.Model\\UserAgent::table_name().'` ua ON ua.id = di.`user_agent_id`\n\t\t\t\tWHERE 1 = 1 AND '.self::analytics_date_condition().'\n\t\t\t\tGROUP BY\n\t\t\t\t\t\tua.client_name\n\t\t\t\tORDER BY\n\t\t\t\t\t\tdownloads DESC;\n\t\t\t', ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'client_name']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_global_sources()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_sources'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_results('\n\t\t\t\tSELECT\n\t\t\t\t\t\tcount(id) downloads,\n\t\t\t\t\t\tsource\n\t\t\t\tFROM\n\t\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().\"\n\t\t\t\tWHERE source IN ('feed', 'webplayer', 'download', 'opengraph') AND \".self::analytics_date_condition().'\n\t\t\t\tGROUP BY\n\t\t\t\t\t\tsource\n\t\t\t\tORDER BY\n\t\t\t\t\t\tdownloads DESC\n\t\t\t', ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'source']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_global_systems()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_systems'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_results('\n\n\t\t\t\tSELECT\n\t\t\t\t\t\tcount(di.id) downloads,\n\t\t\t\t\t\tua.os_name\n\t\t\t\tFROM\n\t\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\t\t\tJOIN `'.Model\\UserAgent::table_name().'` ua ON ua.id = di.`user_agent_id`\n\t\t\t\tWHERE 1 = 1 AND '.self::analytics_date_condition().'\n\t\t\t\tGROUP BY\n\t\t\t\t\t\tua.`os_name`\n\t\t\t\tORDER BY\n\t\t\t\t\t\tdownloads DESC;\n\t\t\t', ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'os_name']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_global_downloads_per_month()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_downloads_per_month'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_results(\"\n\t\t\t\tSELECT\n\t\t\t\t\t\tcount(id),\n\t\t\t\t\t\tDATE_format(accessed_at, '%Y %m') date_month\n\t\t\t\tFROM\n\t\t\t\t\t\t\".Model\\DownloadIntentClean::table_name().' di\n\t\t\t\tWHERE 1 = 1 AND '.self::analytics_date_condition().'\n\t\t\t\tGROUP BY\n\t\t\t\t\t\tdate_month\n\t\t\t\tORDER BY\n\t\t\t\t\t\tdate_month ASC\n\t\t\t', ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'date_month']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public static function analytics_global_total_downloads()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_downloads_total'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $downloads = $wpdb->get_var('\n\t\t\t\tSELECT count(id)\n\t\t\t\tFROM   '.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\tWHERE  '.self::analytics_date_condition().'\n\t\t\t');\n\n            return number_format_i18n($downloads);\n        });\n\n        exit;\n    }\n\n    public static function analytics_global_total_downloads_by_show()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_show_downloads'.self::analytics_date_cache_key(), function () {\n            $downloads = \\Podlove\\Model\\DownloadIntentClean::total_downloads_by_show(self::analytics_date_condition());\n\n            ob_start();\n\n            include 'ajax.analytics_global_total_downloads_by_show.html.php';\n\n            return ob_get_clean();\n        });\n\n        exit;\n    }\n\n    public static function analytics_global_top_episodes()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            exit;\n        }\n\n        \\Podlove\\Feeds\\check_for_and_do_compression('text/plain');\n\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('analytics_global_top_episodes'.self::analytics_date_cache_key(), function () {\n            global $wpdb;\n\n            $sql = '\n\t\t\t\tSELECT\n\t\t\t\t\t\tcount(di.id) downloads,\n\t\t\t\t\t\te.post_id,\n\t\t\t\t\t\tp.post_title\n\t\t\t\tFROM\n\t\t\t\t\t\t'.Model\\DownloadIntentClean::table_name().' di\n\t\t\t\t\t\tINNER JOIN '.Model\\MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\t\t\t\tINNER JOIN '.Model\\Episode::table_name().' e ON e.id = mf.`episode_id`\n\t\t\t\t\t\tINNER JOIN '.$wpdb->posts.' p ON p.`ID` = e.post_id\n\t\t\t\tWHERE 1 = 1 AND '.self::analytics_date_condition().'\n\t\t\t\tGROUP BY p.id\n\t\t\t\tORDER BY downloads DESC\n\t\t\t\tLIMIT 10\n\t\t\t';\n\n            $downloads = $wpdb->get_results($sql, ARRAY_N);\n\n            $csv = Writer::createFromFileObject(new \\SplTempFileObject());\n            $csv->insertOne(['downloads', 'post_id', 'title']);\n            $csv->insertAll($downloads);\n\n            return (string) $csv;\n        });\n\n        ob_end_flush();\n\n        exit;\n    }\n\n    public function podcast()\n    {\n        $podcast = Model\\Podcast::get();\n        $podcast_data = [];\n        foreach ($podcast->property_names() as $property) {\n            $podcast_data[$property] = $podcast->{$property};\n        }\n\n        self::respond_with_json($podcast_data);\n    }\n\n    public function get_new_guid()\n    {\n        $post_id = $_REQUEST['post_id'];\n\n        $post = get_post($post_id);\n        $guid = \\Podlove\\Custom_Guid::guid_for_post($post);\n\n        self::respond_with_json(['guid' => $guid]);\n    }\n\n    public function validate_url()\n    {\n        if (!current_user_can('administrator')) {\n            echo 'No permission';\n\n            exit;\n        }\n\n        $file_url = $_REQUEST['file_url'];\n\n        $r = wp_remote_head($file_url);\n\n        $response_code = $r['response']['code'];\n        $reachable = $response_code >= 200 && $response_code < 300;\n        $content_length = $r['http_response']->get_headers()['content-length'];\n\n        $validation_cache = get_option('podlove_migration_validation_cache', []);\n        $validation_cache[$file_url] = $reachable;\n        update_option('podlove_migration_validation_cache', $validation_cache);\n\n        self::respond_with_json([\n            'file_url' => $file_url,\n            'reachable' => $reachable,\n            'file_size' => $content_length,\n        ]);\n    }\n\n    public function update_asset_position()\n    {\n        if (!current_user_can('administrator')) {\n            echo 'No permission';\n\n            exit;\n        }\n\n        $asset_id = (int) $_REQUEST['asset_id'];\n        $position = (float) $_REQUEST['position'];\n\n        Model\\EpisodeAsset::find_by_id($asset_id)\n            ->update_attributes(['position' => $position])\n        ;\n\n        exit;\n    }\n\n    public function update_feed_position()\n    {\n        if (!current_user_can('administrator')) {\n            echo 'No permission';\n\n            exit;\n        }\n\n        $feed_id = (int) $_REQUEST['feed_id'];\n        $position = (float) $_REQUEST['position'];\n\n        Model\\Feed::find_by_id($feed_id)\n            ->update_attributes(['position' => $position])\n        ;\n\n        exit;\n    }\n\n    public function hide_teaser()\n    {\n        update_option('_podlove_hide_teaser', true);\n    }\n\n    public function banner_hide()\n    {\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_onboarding')) {\n            http_response_code(401);\n            exit;\n        }\n\n        Onboarding::set_banner_hide('true');\n    }\n\n    public function onboarding_acknowledge()\n    {\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_onboarding_acknowledge')) {\n            http_response_code(403);\n            exit;\n        }\n\n        $user_id = get_current_user_id();\n        Onboarding::set_acknowledge_option($user_id, true);\n    }\n\n    public function get_license_url()\n    {\n        self::respond_with_json(\\Podlove\\Model\\License::get_url_from_license(self::parse_get_parameter_into_url_array()));\n    }\n\n    public function get_license_name()\n    {\n        self::respond_with_json(\\Podlove\\Model\\License::get_name_from_license(self::parse_get_parameter_into_url_array()));\n    }\n\n    public function get_license_parameters_from_url()\n    {\n        self::respond_with_json(\\Podlove\\Model\\License::get_license_from_url($_REQUEST['url']));\n    }\n\n    private static function analytics_date_condition()\n    {\n        $from = filter_input(INPUT_GET, 'date_from');\n        $to = filter_input(INPUT_GET, 'date_to');\n\n        if (!$from || !$to) {\n            return '1 = 1';\n        }\n\n        $from = (new \\DateTime($from))->setTime(0, 0, 0);\n        $to = (new \\DateTime($to))->setTime(23, 59, 59);\n\n        if (!$from || !$to) {\n            return '1 = 1';\n        }\n\n        return \"(accessed_at >= \\\"{$from->format('Y-m-d H:i:s')}\\\" AND accessed_at <= \\\"{$to->format('Y-m-d H:i:s')}\\\")\";\n    }\n\n    private static function analytics_date_cache_key()\n    {\n        $condition = self::analytics_date_condition();\n\n        return sha1($condition);\n    }\n\n    private function parse_get_parameter_into_url_array()\n    {\n        return [\n            'version' => $_REQUEST['version'],\n            'modification' => $_REQUEST['modification'],\n            'commercial_use' => $_REQUEST['commercial_use'],\n            'jurisdiction' => $_REQUEST['jurisdiction'],\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/ajax/file_controller.php",
    "content": "<?php\n\nnamespace Podlove\\AJAX;\n\nuse Podlove\\Model\\MediaFile;\n\nclass FileController\n{\n    public static function init()\n    {\n        $actions = [\n            'update', 'create',\n        ];\n\n        foreach ($actions as $action) {\n            add_action('wp_ajax_podlove-file-'.$action, [__CLASS__, str_replace('-', '_', $action)]);\n        }\n    }\n\n    public static function update()\n    {\n        $file_id = (int) $_REQUEST['file_id'];\n\n        $file = MediaFile::find_by_id($file_id);\n\n        if (isset($_REQUEST['slug'])) {\n            self::simulate_temporary_episode_slug($_REQUEST['slug']);\n        }\n\n        $info = $file->determine_file_size();\n        $file->save();\n\n        $result = [];\n        $result['file_url'] = $file->get_file_url();\n        $result['active'] = (bool) $file->active;\n        $result['file_id'] = $file_id;\n        $result['reachable'] = podlove_is_resolved_and_reachable_http_status($info['http_code']);\n        $result['file_size'] = $file->size;\n        $result['file_size_human'] = number_format_i18n($file->size);\n\n        if (!$result['reachable']) {\n            $info['certinfo'] = print_r($info['certinfo'], true);\n            $info['php_open_basedir'] = ini_get('open_basedir');\n            $info['php_curl'] = in_array('curl', get_loaded_extensions());\n            $info['curl_exec'] = function_exists('curl_exec');\n\n            \\Podlove\\Log::get()->addError(\"Can't reach {$file->get_file_url()}\", $info);\n        } else {\n            do_action('podlove_media_file_content_verified', $file->id);\n        }\n\n        Ajax::respond_with_json($result);\n    }\n\n    public static function create()\n    {\n        $episode_id = (int) $_REQUEST['episode_id'];\n        $episode_asset_id = (int) $_REQUEST['episode_asset_id'];\n\n        if (!$episode_id || !$episode_asset_id) {\n            exit;\n        }\n\n        if (isset($_REQUEST['slug'])) {\n            self::simulate_temporary_episode_slug($_REQUEST['slug']);\n        }\n\n        $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode_id, $episode_asset_id);\n\n        Ajax::respond_with_json([\n            'file_id' => $file->id,\n            'file_size' => $file->size,\n            'file_url' => $file->get_file_url(),\n        ]);\n    }\n\n    private static function simulate_temporary_episode_slug($slug)\n    {\n        add_filter('podlove_file_url_template', function ($template) use ($slug) {\n            return str_replace('%episode_slug%', \\Podlove\\prepare_episode_slug_for_url($slug), $template);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/ajax/template_controller.php",
    "content": "<?php\n\nnamespace Podlove\\AJAX;\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Model\\Template;\n\nclass TemplateController\n{\n    public static function init()\n    {\n        $actions = [\n            'get', 'update', 'create', 'delete',\n        ];\n\n        foreach ($actions as $action) {\n            if (isset($_REQUEST['is_network']) && $_REQUEST['is_network'] == 'yes') {\n                // No need to deactivate the scope because the script dies\n                // after the main action anyway.\n                add_action('wp_ajax_podlove-template-'.$action, [__CLASS__, 'activate_network_scope'], 9);\n            }\n\n            add_action('wp_ajax_podlove-template-'.$action, [__CLASS__, str_replace('-', '_', $action)]);\n        }\n    }\n\n    public static function activate_network_scope()\n    {\n        Template::activate_network_scope();\n    }\n\n    public static function get()\n    {\n        if (!current_user_can('administrator')) {\n            Ajax::respond_with_json(['success' => false]);\n        }\n\n        $id = filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT);\n\n        if ($template = Template::find_by_id($id)) {\n            $response = [\n                'id' => $template->id,\n                'title' => $template->title,\n                'content' => $template->content,\n            ];\n        } else {\n            $response = [];\n        }\n\n        Ajax::respond_with_json($response);\n    }\n\n    public static function update()\n    {\n        if (!current_user_can('administrator')) {\n            Ajax::respond_with_json(['success' => false]);\n        }\n\n        if (!\\wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $id = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_NUMBER_INT);\n        $title = filter_input(INPUT_POST, 'title');\n        $content = filter_input(INPUT_POST, 'content');\n\n        if (!$id || !$title) {\n            Ajax::respond_with_json(['success' => false]);\n        }\n\n        $template = Template::find_by_id($id);\n        $template->title = $title;\n        $template->content = $content;\n        $template->save();\n\n        if (is_network_admin()) {\n            TemplateCache::get_instance()->setup_purge_in_all_blogs();\n            TemplateCache::get_instance()->purge();\n        } else {\n            TemplateCache::get_instance()->purge();\n        }\n\n        Ajax::respond_with_json(['success' => true]);\n    }\n\n    public static function create()\n    {\n        if (!current_user_can('administrator')) {\n            Ajax::respond_with_json(['success' => false]);\n        }\n\n        if (!\\wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $template = new Template();\n        $template->title = 'new template';\n        $template->save();\n\n        Ajax::respond_with_json(['id' => $template->id]);\n    }\n\n    public static function delete()\n    {\n        if (!current_user_can('administrator')) {\n            Ajax::respond_with_json(['success' => false]);\n        }\n\n        if (!\\wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $id = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_NUMBER_INT);\n        $template = Template::find_by_id($id);\n\n        if (!$id || !$template) {\n            Ajax::respond_with_json(['success' => false]);\n        } else {\n            $template->delete();\n            Ajax::respond_with_json(['success' => true]);\n        }\n    }\n}\n"
  },
  {
    "path": "lib/analytics/download_intent_cleanup.php",
    "content": "<?php\n\nnamespace Podlove\\Analytics;\n\nuse Podlove\\Jobs\\CronJobRunner;\n\n/**\n * Cron manager to fill DownloadIntentClean table.\n */\nclass DownloadIntentCleanup\n{\n    public static function init()\n    {\n        self::schedule_crons();\n\n        add_action('podlove_cleanup_download_intents', [__CLASS__, 'cleanup_download_intents']);\n    }\n\n    public static function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_cleanup_download_intents')) {\n            wp_schedule_event(time(), 'hourly', 'podlove_cleanup_download_intents');\n        }\n    }\n\n    public static function cleanup_download_intents()\n    {\n        CronJobRunner::create_job('\\Podlove\\Jobs\\DownloadIntentCleanupJob', ['delete_all' => false]);\n    }\n}\n"
  },
  {
    "path": "lib/analytics/download_sums_calculator.php",
    "content": "<?php\n\nnamespace Podlove\\Analytics;\n\nuse Podlove\\Jobs\\CronJobRunner;\n\nclass DownloadSumsCalculator\n{\n    public static function init()\n    {\n        self::schedule_crons();\n\n        add_action('podlove_calc_hourly_download_sums', [__CLASS__, 'calc_hourly_download_sums']);\n        add_action('podlove_calc_daily_download_sums', [__CLASS__, 'calc_daily_download_sums']);\n    }\n\n    public static function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_calc_hourly_download_sums')) {\n            wp_schedule_event(time(), 'hourly', 'podlove_calc_hourly_download_sums');\n        }\n\n        if (!wp_next_scheduled('podlove_calc_daily_download_sums')) {\n            wp_schedule_event(time(), 'daily', 'podlove_calc_daily_download_sums');\n        }\n    }\n\n    public static function calc_hourly_download_sums()\n    {\n        $job = CronJobRunner::create_job('\\Podlove\\Jobs\\DownloadTimedAggregatorJob', [\n            'force' => false,\n        ]);\n    }\n\n    public static function calc_daily_download_sums()\n    {\n        $job = CronJobRunner::create_job('\\Podlove\\Jobs\\DownloadTimedAggregatorJob', [\n            'force' => true,\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/analytics/episode_download_average.php",
    "content": "<?php\n\nnamespace Podlove\\Analytics;\n\n/**\n * Calculate download averages for episodes.\n *\n * Calculating EDAs is costly, that's why intermediate results are calculated\n * and stored separately. The goal is to generate a graph displaying average\n * downloads over all episodes, relative to each release date. Each episode\n * stores the download data for the first n hours as a post_meta.\n */\nclass EpisodeDownloadAverage\n{\n    public const HOURS_TO_CALCULATE = 800; // roughly a month\n\n    public static function init()\n    {\n        self::schedule_crons();\n\n        add_action('recalculate_episode_download_average', [__CLASS__, 'recalculate_episode_download_average']);\n    }\n\n    public static function schedule_crons()\n    {\n        if (!wp_next_scheduled('recalculate_episode_download_average')) {\n            wp_schedule_event(time(), 'daily', 'recalculate_episode_download_average');\n        }\n    }\n\n    public static function recalculate_episode_download_average()\n    {\n        set_time_limit(1800); // set max_execution_time to half an hour\n\n        $query = new \\WP_Query([\n            'post_type' => 'podcast',\n            'post_status' => ['publish', 'private'],\n            'posts_per_page' => -1,\n            'meta_query' => [\n                [\n                    'key' => '_podlove_eda_complete',\n                    'compare' => 'NOT EXISTS',\n                ],\n            ],\n        ]);\n\n        while ($query->have_posts()) {\n            $query->the_post();\n            $post_id = get_the_ID();\n            $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id);\n            $downloads = self::get_downloads_per_hour_for_episode($episode->id);\n            update_post_meta($post_id, '_podlove_eda_downloads', implode(',', $downloads));\n\n            if (count($downloads) >= self::HOURS_TO_CALCULATE) {\n                update_post_meta($post_id, '_podlove_eda_complete', 1);\n            }\n        }\n\n        wp_reset_postdata();\n    }\n\n    private static function get_downloads_per_hour_for_episode($episode_id)\n    {\n        global $wpdb;\n\n        $sql = \"\n\t\t\tSELECT\n\t\t\t  \tCOUNT(*) downloads, DATE_FORMAT(accessed_at, '%%Y-%%m-%%d %%H') AS access_hour\n\t\t\tFROM\t\t\n\t\t\t\t\t`\".\\Podlove\\Model\\DownloadIntentClean::table_name().'` di \n\t\t\t\t\tINNER JOIN `'.\\Podlove\\Model\\MediaFile::table_name().'` mf ON mf.id = di.media_file_id\n\t\t\t\t\tWHERE episode_id = %d\n\t\t\tGROUP BY access_hour\n\t\t\tORDER BY access_hour\n\t\t\tLIMIT %d\n\t\t';\n\n        $data = $wpdb->get_results(\n            $wpdb->prepare($sql, $episode_id, self::HOURS_TO_CALCULATE),\n            ARRAY_A\n        );\n\n        $release_date = $wpdb->get_var(\n            $wpdb->prepare(\n                \"SELECT post_date FROM {$wpdb->posts} p JOIN \".\\Podlove\\Model\\Episode::table_name().' e ON e.post_id = p.ID WHERE e.id = %d',\n                $episode_id\n            )\n        );\n\n        if ($data) {\n            $missing_hours = self::add_missing_hours($data, $release_date);\n            array_splice($missing_hours, self::HOURS_TO_CALCULATE);\n\n            return array_column($missing_hours, 'downloads');\n        }\n\n        return [];\n    }\n\n    /**\n     * $data is an associative array with downloads and datetime column in hour-accuracy.\n     * This method adds 0-download-entries for missing hours.\n     *\n     * @todo add entries *before* first item (actually ... for current use case not required)\n     *\n     * @param mixed $data\n     * @param mixed $release_date\n     */\n    private static function add_missing_hours($data, $release_date)\n    {\n        $time_format = 'Y-m-d H';\n        $release_date = \\DateTime::createFromFormat('Y-m-d H:i:s', $release_date);\n\n        return array_reduce($data, function ($agg, $item) use ($time_format, $release_date) {\n            $cur_time = \\DateTime::createFromFormat($time_format, $item['access_hour']);\n\n            if (empty($agg)) {\n                $date_diff = $release_date->diff($cur_time);\n\n                // only fill if release date is older than first item\n                if (!$date_diff->invert) {\n                    $hour_diff = $date_diff->h + $date_diff->d * 24 + $date_diff->m * 30 * 24 + $date_diff->y * 365 * 24;\n                    $hour_diff = min($hour_diff, EpisodeDownloadAverage::HOURS_TO_CALCULATE); // don't generate data that will be deleted later\n\n                    // fill with 0 entries for every missing hour\n                    for ($i = $hour_diff; $i > 1; --$i) {\n                        $release_date->add(\\DateInterval::createFromDateString('1 hour'));\n                        $agg[] = [\n                            'downloads' => 0,\n                            'access_hour' => $release_date->format($time_format),\n                        ];\n                    }\n                }\n\n                $agg[] = $item;\n            } else {\n                $last_item = end($agg);\n                $last_time = \\DateTime::createFromFormat($time_format, $last_item['access_hour']);\n                $date_diff = $last_time->diff($cur_time);\n                $hour_diff = $date_diff->h + $date_diff->d * 24;\n\n                // fill with 0 entries for every missing hour\n                for ($i = $hour_diff; $i > 1; --$i) {\n                    $last_time->add(\\DateInterval::createFromDateString('1 hour'));\n                    $agg[] = [\n                        'downloads' => 0,\n                        'access_hour' => $last_time->format($time_format),\n                    ];\n                }\n                // add the current item\n                $agg[] = $item;\n            }\n\n            return $agg;\n        }, []);\n    }\n}\n"
  },
  {
    "path": "lib/analytics/salt_shaker.php",
    "content": "<?php\n\nnamespace Podlove\\Analytics;\n\nuse Podlove\\Jobs\\CronJobRunner;\n\n/**\n * Cron manager to salt request_ids in DownloadIntentClean table.\n */\nclass SaltShaker\n{\n    public static function init()\n    {\n        self::schedule_crons();\n\n        add_action('podlove_salt_download_intents', [__CLASS__, 'cleanup_download_intents']);\n    }\n\n    public static function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_salt_download_intents')) {\n            $three_am = strtotime(date('Y-m-d').' 03:00:00');\n            wp_schedule_event($three_am, 'daily', 'podlove_salt_download_intents');\n        }\n    }\n\n    public static function cleanup_download_intents()\n    {\n        CronJobRunner::create_job('\\Podlove\\Jobs\\RequestIdRehashJob');\n    }\n}\n"
  },
  {
    "path": "lib/api/error.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Error;\n\nclass ForbiddenAccess extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $code\n     * @param mixed $message\n     */\n    public function __construct($code = '', $message = '')\n    {\n        if (strlen($code) == 0) {\n            $code = 'rest_forbidden';\n        }\n        if (strlen($message) == 0) {\n            $message = esc_html__('sorry, you do not have permissions to use this REST API endpoint');\n        }\n        parent::__construct($code, $message, ['status' => 401]);\n    }\n}\n\nclass NotFound extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $code\n     * @param mixed $message\n     */\n    public function __construct($code = '', $message = '')\n    {\n        if (strlen($code) == 0) {\n            $code = 'rest_not_found';\n        }\n        if (strlen($message) == 0) {\n            $message = esc_html__('sorry, we did not find the requested resource');\n        }\n        parent::__construct($code, $message, ['status' => 404]);\n    }\n}\n\nclass NotFoundEpisode extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $episode_id\n     */\n    public function __construct($episode_id)\n    {\n        $message = 'sorry, we did not find the episode with ID '.$episode_id;\n        parent::__construct('not_found', esc_html__($message), ['status' => 404]);\n    }\n}\n\nclass NotSupported extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $code\n     * @param mixed $message\n     */\n    public function __construct($code = '', $message = '')\n    {\n        if (strlen($code) == 0) {\n            $code = 'rest_not_supported';\n        }\n        if (strlen($message) == 0) {\n            $message = esc_html__('sorry, we do not support your request');\n        }\n        parent::__construct($code, $message, ['status' => 415]);\n    }\n}\n\nclass ArgumentError extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $code\n     * @param mixed $message\n     */\n    public function __construct($code = '', $message = '')\n    {\n        if (strlen($code) == 0) {\n            $code = 'rest_forbidden';\n        }\n        if (strlen($message) == 0) {\n            $message = esc_html__('invalid argument');\n        }\n        parent::__construct($code, $message, ['status' => 400]);\n    }\n}\n\nclass InternalServerError extends \\WP_Error\n{\n    /**\n     * Constructor.\n     *\n     * @param mixed $code\n     * @param mixed $message\n     */\n    public function __construct($code = '', $message = '')\n    {\n        if (strlen($code) == 0) {\n            $code = 'rest_internal_server_error';\n        }\n        if (strlen($message) == 0) {\n            $message = esc_html__('sorry, we have an internal error');\n        }\n        parent::__construct($code, $message, ['status' => 500]);\n    }\n}\n"
  },
  {
    "path": "lib/api/permissions.php",
    "content": "<?php\n\nnamespace Podlove\\Api;\n\nclass Permissons\n{\n    public static function authorization_status_code()\n    {\n        $status = 401;\n\n        if (is_user_logged_in()) {\n            $status = 403;\n        }\n\n        return $status;\n    }\n}\n"
  },
  {
    "path": "lib/api/response.php",
    "content": "<?php\n\nnamespace Podlove\\Api\\Response;\n\nclass OkResponse extends \\WP_REST_Response\n{\n    /**\n     * Constructor.\n     *\n     * @param null|mixed $data\n     */\n    public function __construct($data = null, array $headers = [])\n    {\n        parent::__construct($data, 200, $headers);\n    }\n}\n\nclass CreateResponse extends \\WP_REST_Response\n{\n    /**\n     * Constructor.\n     *\n     * @param null|mixed $data\n     */\n    public function __construct($data = null, array $headers = [])\n    {\n        parent::__construct($data, 201, $headers);\n    }\n}\n"
  },
  {
    "path": "lib/api/validation.php",
    "content": "<?php\n\nnamespace Podlove\\Api;\n\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\DefaultContribution;\nuse Podlove\\NormalPlayTime;\n\nclass Validation\n{\n    public static function timestamp($param, $request, $key)\n    {\n        if (!isset($param)) {\n            return false;\n        }\n\n        $npt = NormalPlayTime\\Parser::parse($param, 'ms');\n        if ($npt === false) {\n            return false;\n        }\n\n        return true;\n    }\n\n    public static function url($param, $request, $key)\n    {\n        if (empty($param)) {\n            return false;\n        }\n\n        if (preg_match('/\\b(?:(?:https?|ftp):\\/\\/|www\\.)[-a-z0-9+&@#\\/%?=~_|!:,.;]*[-a-z0-9+&@#\\/%=~_|]/i', $param)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    public static function episodeCover($param, $request, $key)\n    {\n        $asset_assignment = Podlove\\Model\\AssetAssignment::get_instance();\n        if ($asset_assignment->image == 'manual') {\n            if (isset($param['cover'])) {\n                $cover = $param['cover'];\n                if (!Validation::url($cover, $request, $key)) {\n                    return false;\n                }\n\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    public static function maxLength255($param, $request, $key)\n    {\n        if (isset($param) && gettype($param) == 'string') {\n            if (strlen($param) <= 255) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    public static function chapters($param, $request, $key)\n    {\n        if (isset($param) && is_array($param)) {\n            for ($i = 0; $i < count($param); ++$i) {\n                $timestamp = '';\n                if (isset($param[$i]['start'])) {\n                    $timestamp = $param[$i]['start'];\n                    if (!Validation::timestamp($timestamp, $request, $key)) {\n                        return false;\n                    }\n                }\n                $title = '';\n                if (isset($param[$i]['title'])) {\n                    $title = $param[$i]['title'];\n                } else {\n                    return false;\n                }\n                $url = '';\n                if (isset($param[$i]['url'])) {\n                    $url = $param[$i]['url'];\n                    if (!Validation::url($url, $request, $key)) {\n                        return false;\n                    }\n                }\n            }\n        }\n\n        return true;\n    }\n\n    public static function isContributorIdExist($param, $request, $key)\n    {\n        if (isset($param)) {\n            $id = $param;\n            $contributor = Contributor::find_by_id($id);\n            if (!$contributor) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    public static function isContributorGroupIdExist($param, $request, $key)\n    {\n        if (isset($param)) {\n            $id = $param;\n            $group = ContributorGroup::find_by_id($id);\n            if (!$group) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    public static function isContributorRoleIdExist($param, $request, $key)\n    {\n        if (isset($param)) {\n            $id = $param;\n            $role = ContributorRole::find_by_id($id);\n            if (!$role) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    public static function isContributorDefaultIdExist($param, $request, $key)\n    {\n        if (isset($param)) {\n            $id = $param;\n            $contributor = DefaultContribution::find_one_by_property('contributor_id', $id);\n            if (!$contributor) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "lib/authentication.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Authentication methods for Podlove Publisher.\n *\n * Example usage:\n *\n * \t$applicationPassword = Authentication::application_password();\n * \t$applicationPassword['name'] -> username\n * \t$applicationPassword['password'] -> password\n */\nclass Authentication\n{\n    private static $appId = 'podlove-publisher';\n    private static $appName = 'Podlove Publisher';\n\n    // generates a one time password for authentication\n    public static function application_password()\n    {\n        $user = wp_get_current_user();\n\n        $applicationPasswords = \\WP_Application_Passwords::get_user_application_passwords($user->data->ID);\n\n        // find the app by the provided id since wordpress only has helper functions for their generated uuid ...\n        $publisherApp = current(array_filter($applicationPasswords, function ($app) {\n            return $app['app_id'] == self::$appId;\n        }));\n\n        // delete the existing password\n        if ($publisherApp) {\n            \\WP_Application_Passwords::delete_application_password($user->data->ID, $publisherApp['uuid']);\n        }\n\n        $appPassword = \\WP_Application_Passwords::create_new_application_password($user->data->ID, [\n            'name' => self::$appName,\n            'app_id' => self::$appId,\n        ]);\n\n        return [\n            'name' => $user->data->user_login,\n            'password' => current($appPassword),\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/cache/http_header_validator.php",
    "content": "<?php\n\nnamespace Podlove\\Cache;\n\n/**\n * Checks HTTP header of URL for changrs in etag or last_modified.\n *\n * Usage\n *\n * \t$validator = new HttpHeaderValidator('http://example.com/resource.jpg', $etag, $last_modified);\n * \t$validator->validate();\n * \tif ($validator->hasChanged()) {\n * \t\t// etag or last_modified have changed\n * \t}\n */\nclass HttpHeaderValidator\n{\n    private $url;\n    private $etag;\n    private $last_modified;\n\n    private $has_changed = false;\n\n    public function __construct($url, $etag = null, $last_modified = null)\n    {\n        $this->url = $url;\n        $this->etag = $etag;\n        $this->last_modified = $last_modified;\n    }\n\n    public function validate()\n    {\n        $response = wp_remote_head($this->url);\n\n        // Might just be unavailable right now, so ignore.\n        // It would be great to track this over time and create conflicts.\n        if (is_wp_error($response)) {\n            return;\n        }\n\n        $remote_etag = wp_remote_retrieve_header($response, 'etag');\n        $remote_last_modified = wp_remote_retrieve_header($response, 'last-modified');\n\n        if ($this->etag || $remote_etag) {\n            if ($this->etag != $remote_etag) {\n                $this->has_changed = true;\n            }\n        }\n\n        if ($this->last_modified || $remote_last_modified) {\n            if ($this->last_modified != $remote_last_modified) {\n                $this->has_changed = true;\n            }\n        }\n\n        // @fixme: what to do if both etag and last_modified are missing?\n        // right now those cases never count as \"changed\"\n    }\n\n    public function hasChanged()\n    {\n        return $this->has_changed;\n    }\n}\n"
  },
  {
    "path": "lib/cache/template_cache.php",
    "content": "<?php\n\nnamespace Podlove\\Cache;\n\nuse Podlove\\Model;\n\n/**\n * Template Caching.\n *\n * API to cache rendered text strings.\n * This cache does *not* expire by time. Instead, purging is also handled by\n * this class. Whenever a view-relevant model changes, *all* caches are purged.\n * To register a model class for purging, the 'podlove_cache_tainting_classes'\n * filter has to be used. Look into the source below for usage. Alternatively,\n * a purge can be started manually:\n *\n * \t\\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n *\n * Usage example:\n *\n * \t$cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n * \t$html = $cache->cache_for('unique_cache_key', function() {\n * \t\treturn \"Hello World\"; // or probably something more costly to generate\n * \t});\n *\n * To globally deactivate caching, put this in the wp-config.php:\n *\n * \tdefine('PODLOVE_TEMPLATE_CACHE', false);\n */\nclass TemplateCache\n{\n    public const CACHE_NAMESPACE = 'podlove_cachev2_';\n    public const CRON_PURGE_HOOK = 'podlove_purge_template_cache';\n    private static $instance;\n\n    /**\n     * If the cache is tainted, it has to be purged.\n     *\n     * @var bool\n     */\n    private $is_tainted = false;\n\n    protected function __construct()\n    {\n        register_shutdown_function([$this, 'maybe_purge']);\n        add_action(self::CRON_PURGE_HOOK, [$this, 'purge']);\n        add_action('podlove_model_change', [$this, 'handle_model_change']);\n    }\n\n    private function __clone() {}\n\n    /**\n     * Singleton.\n     *\n     * @return TemplateCache\n     */\n    public static function get_instance()\n    {\n        if (!isset(self::$instance)) {\n            self::$instance = new self();\n        }\n\n        return self::$instance;\n    }\n\n    public static function is_enabled()\n    {\n        return !defined('PODLOVE_TEMPLATE_CACHE') || PODLOVE_TEMPLATE_CACHE;\n    }\n\n    public function handle_model_change($model)\n    {\n        $tainting_classes = [\n            Model\\Episode::name(),\n            Model\\Feed::name(),\n            // MediaFile is troublesome because it is saved on validation;\n            // but thinking about it: it shouldn't affect cache anyway ... ?\n            // Model\\MediaFile::name(),\n            Model\\Podcast::name(),\n            Model\\Template::name(),\n            Model\\TemplateAssignment::name(),\n        ];\n\n        $tainting_classes = apply_filters('podlove_cache_tainting_classes', $tainting_classes);\n\n        if (in_array($model::name(), $tainting_classes)) {\n            $this->taint();\n        }\n    }\n\n    public function taint()\n    {\n        $this->is_tainted = true;\n    }\n\n    public function maybe_purge()\n    {\n        if ($this->is_tainted) {\n            $this->setup_purge();\n        }\n    }\n\n    /**\n     * Schedule async cache purge *now*.\n     */\n    public function setup_purge()\n    {\n        if (!wp_next_scheduled(self::CRON_PURGE_HOOK)) {\n            wp_schedule_single_event(time(), self::CRON_PURGE_HOOK);\n        }\n    }\n\n    /**\n     * Setup cache purge in all blogs.\n     */\n    public function setup_purge_in_all_blogs()\n    {\n        global $wpdb;\n\n        \\Podlove\\for_every_podcast_blog(function () {\n            TemplateCache::get_instance()->setup_purge();\n        });\n    }\n\n    /**\n     * Setup complete purge, depending on if we are a Multisite.\n     */\n    public function setup_global_purge()\n    {\n        if (is_multisite()) {\n            TemplateCache::get_instance()->setup_purge_in_all_blogs();\n        } else {\n            TemplateCache::get_instance()->setup_purge();\n        }\n    }\n\n    /**\n     * Purge all caches.\n     *\n     * @todo Purging cache by DELETE query works for DB-storage only.\n     * In previous versions, I memorized all generated cache keys but that\n     * lead to its own set of problems (race conditions, db locks because\n     * it's a huge value that is written to very often, ...).\n     * I would prefer either a `delete_all_transients()` or `delete_transient_matching(<string>)`\n     * method. However, WordPress only lets you delete by exact key.\n     *\n     * That's why, at the moment, purging only works for DB storage (which is the default).\n     * Other caches expire automatically after 24 hours.\n     */\n    public function purge()\n    {\n        global $wpdb;\n\n        // quick, reliable purge (but only works with database as backend)\n        $sql = \"DELETE FROM {$wpdb->options} WHERE option_name LIKE \\\"_transient_\".self::CACHE_NAMESPACE.'%\"';\n        $wpdb->query($sql);\n        $sql = \"DELETE FROM {$wpdb->options} WHERE option_name LIKE \\\"_transient_timeout_\".self::CACHE_NAMESPACE.'%\"';\n        $wpdb->query($sql);\n    }\n\n    /**\n     * Fetch and/or fill cache for given key.\n     *\n     * @param string   $cache_key  must be unique for the given template and context\n     * @param function $callback   the function that generates the content in case of a cache miss\n     * @param mixed    $expiration\n     *\n     * @return string content for given cache key\n     */\n    public function cache_for($cache_key, $callback, $expiration = DAY_IN_SECONDS)\n    {\n        if (!self::is_enabled()) {\n            return call_user_func($callback);\n        }\n\n        $cache_key = $this->generate_cache_key($cache_key);\n\n        if (($html = get_transient($cache_key)) !== false) {\n            return $html;\n        }\n        $html = call_user_func($callback);\n\n        if ($html !== false) {\n            set_transient($cache_key, $html, $expiration);\n        }\n\n        return $html;\n    }\n\n    public function delete_cache_for($cache_key)\n    {\n        $cache_key = $this->generate_cache_key($cache_key);\n\n        return delete_transient($cache_key);\n    }\n\n    public function expiration_for($cache_key)\n    {\n        $cache_key = $this->generate_cache_key($cache_key);\n\n        return get_option('_transient_timeout_'.$cache_key);\n    }\n\n    private function memorize_cache_key($cache_key)\n    {\n        $cache_keys = get_option('podlove_tpl_cache_keys', '');\n\n        if (strlen($cache_keys)) {\n            $cache_keys .= ','.$cache_key;\n        } else {\n            $cache_keys = $cache_key;\n        }\n        update_option('podlove_tpl_cache_keys', $cache_keys);\n    }\n\n    /**\n     * Generate a valid cache key.\n     *\n     * - assumes, given $cache_key is unique\n     * - adds \"podlove_cache_\" as namespace\n     * - apply sha1 because we may have to cut off the end of the key. And if the\n     *   variation only happens in the end of the keys (example: permalinks), that\n     *   would lead to cache collisions. sha1-ing avoids this.\n     * - ensures key is not too long:\n     * \tCache key must not be longer than 64 characters!\n     *  Transients API prepends \"_transient_timeout_\", 19 characters\n     *  64 - 19 = 45 (minus one because you never know)\n     *\n     * @param mixed $cache_key\n     *\n     * @return string\n     */\n    private function generate_cache_key($cache_key)\n    {\n        $cache_key = sprintf('%s%s', self::CACHE_NAMESPACE, sha1($cache_key));\n\n        return substr($cache_key, 0, 44);\n    }\n}\n"
  },
  {
    "path": "lib/chapters_manager.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Chapters\\Parser;\nuse Podlove\\Chapters\\Printer;\n\n/**\n * Convenience wrapper for episode chapters.\n *\n * Handles caching of chapters.\n */\nclass ChaptersManager\n{\n    private $episode;\n    private $chapters_raw = '';\n    private $chapters_object;\n\n    public function __construct(Model\\Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    /**\n     * Get episode chapters.\n     *\n     * @param string $format object, psc, mp4chaps, json. Default: object\n     *\n     * @return mixed\n     */\n    public function get($format = 'object')\n    {\n        if (!$this->chapters_object) {\n            $this->chapters_object = $this->get_chapters_object();\n        }\n\n        if (!$this->chapters_object) {\n            return '';\n        }\n\n        switch ($format) {\n            case 'psc':\n                $this->chapters_object->setPrinter(new Printer\\PSC());\n\n                return (string) $this->chapters_object;\n            case 'mp4chaps':\n                $this->chapters_object->setPrinter(new Printer\\Mp4chaps());\n\n                return (string) $this->chapters_object;\n            case 'json':\n                $this->chapters_object->setPrinter(new Printer\\JSON());\n\n                return (string) $this->chapters_object;\n            case 'pijson':\n                $this->chapters_object->setPrinter(new Printer\\PodcastIndexJSON());\n\n                return (string) $this->chapters_object;\n        }\n\n        return $this->chapters_object;\n    }\n\n    private function get_raw_chapters_string()\n    {\n        $asset_assignment = Model\\AssetAssignment::get_instance();\n        $cache_key = 'podlove_chapters_string_'.$this->episode->id;\n        if (($chapters_string = get_transient($cache_key)) !== false) {\n            return $chapters_string;\n        }\n        if ($asset_assignment->chapters == 'manual') {\n            return $this->episode->chapters;\n        }\n        if (!$chapters_asset = Model\\EpisodeAsset::find_one_by_id($asset_assignment->chapters)) {\n            return '';\n        }\n\n        if (!$chapters_file = Model\\MediaFile::find_by_episode_id_and_episode_asset_id($this->episode->id, $chapters_asset->id)) {\n            return '';\n        }\n\n        $chapters_string = wp_remote_get($chapters_file->get_file_url());\n\n        if (is_wp_error($chapters_string)) {\n            return '';\n        }\n\n        set_transient($cache_key, $chapters_string['body'], 60 * 60 * 24 * 365); // 1 year, we devalidate manually\n\n        return $chapters_string['body'];\n    }\n\n    private function get_chapters_object()\n    {\n        if (!$this->chapters_raw) {\n            $this->chapters_raw = $this->get_raw_chapters_string();\n        }\n\n        if (!$this->chapters_raw) {\n            return null;\n        }\n\n        $asset_assignment = Model\\AssetAssignment::get_instance();\n\n        if ($asset_assignment->chapters == 'manual') {\n            switch ($this->chapters_raw[0]) {\n                case '[':\n                case '{':\n                    return Parser\\JSON::parse($this->chapters_raw);\n\n                    break;\n\n                default:\n                    // for backwards compatibility\n                    return Parser\\Mp4chaps::parse($this->chapters_raw);\n\n                    break;\n            }\n        }\n\n        if (!$chapters_asset = Model\\EpisodeAsset::find_one_by_id($asset_assignment->chapters)) {\n            return null;\n        }\n\n        $mime_type = $chapters_asset->file_type()->mime_type;\n        $chapters = false;\n\n        switch ($mime_type) {\n            case 'application/xml':\n                $chapters = Parser\\PSC::parse($this->chapters_raw);\n\n                break;\n            case 'application/json':\n                $chapters = Parser\\JSON::parse($this->chapters_raw);\n\n                break;\n            case 'text/plain':\n                switch ($this->chapters_raw[0]) {\n                    case '[':\n                    case '{':\n                        $chapters = Parser\\JSON::parse($this->chapters_raw);\n\n                        break;\n                    case '<':\n                        $chapters = Parser\\PSC::parse($this->chapters_raw);\n\n                        break;\n\n                    default:\n                        $chapters = Parser\\Mp4chaps::parse($this->chapters_raw);\n\n                        break;\n                }\n\n                break;\n        }\n\n        return $chapters;\n    }\n}\n"
  },
  {
    "path": "lib/comment/comment.php",
    "content": "<?php\n\nnamespace Podlove\\Comment;\n\nclass Comment\n{\n    // the original comment text\n    private $comment;\n\n    // array with lines to parse\n    private $lines;\n\n    private $title;\n    private $description;\n    private $tags = [];\n\n    public function __construct($comment)\n    {\n        $this->comment = $comment;\n    }\n\n    public function parse()\n    {\n        $c = $this->comment;\n        $c = $this->removeFirstLine($c);\n        $c = $this->removeLastLine($c);\n        $c = $this->removeLeadingStars($c);\n        $c = $this->removeOneLeadingWhitespace($c);\n\n        $this->lines = explode(\"\\n\", $c);\n\n        $this->title = trim($this->lines[0]);\n\n        if (count($this->lines) === 1) {\n            return;\n        }\n\n        $this->assert(empty($this->lines[1]), 'Second comment line must be empty');\n\n        $this->extractTags();\n        $this->extractDescription();\n    }\n\n    /**\n     * Get comment title.\n     *\n     * @return string\n     */\n    public function getTitle()\n    {\n        return $this->title;\n    }\n\n    /**\n     * Get comment description.\n     *\n     * @return string\n     */\n    public function getDescription()\n    {\n        return $this->description;\n    }\n\n    /**\n     * Filter tags by name.\n     *\n     * @param string $tagName Filter tags by name\n     *\n     * @return array All matching tags\n     */\n    public function getTags($tagName = null)\n    {\n        if (!$tagName) {\n            return $this->tags;\n        }\n\n        return array_values(\n            array_filter($this->tags, function ($tag) use ($tagName) {\n                return $tagName == $tag['name'];\n            })\n        );\n    }\n\n    /**\n     * Get tag by name.\n     *\n     * @param string $tagName Filter tags by name\n     *\n     * @return array First matching tag\n     */\n    public function getTag($tagName)\n    {\n        return $this->getTags($tagName)[0];\n    }\n\n    private function assert($condition, $message = '')\n    {\n        assert($condition, $message.\"\\nComment:\\n\".$this->comment);\n    }\n\n    private function removeFirstLine($c)\n    {\n        $new = preg_replace(\"/^\\\\/\\\\*\\\\*\\\\s*\\n/\", '', $c, -1, $count);\n        $this->assert($count === 1, 'Comments must start with /**');\n\n        return $new;\n    }\n\n    private function removeLastLine($c)\n    {\n        $new = preg_replace('/\\s*\\*\\/\\s*$/', '', $c, -1, $count);\n        $this->assert($count === 1, 'Comments must end with */');\n\n        return $new;\n    }\n\n    private function removeLeadingStars($c)\n    {\n        $new = preg_replace('/^\\s*\\*/m', '', $c, -1, $count);\n        $this->assert($count > 0, 'Comment lines must start with *');\n\n        return $new;\n    }\n\n    private function removeOneLeadingWhitespace($c)\n    {\n        return preg_replace_callback('/^.*$/m', function ($m) {\n            return preg_replace('/^\\s/', '', $m[0], 1);\n        }, $c);\n    }\n\n    private function extractTags()\n    {\n        $lineNo = count($this->lines) - 1;\n        $continue = true;\n\n        do {\n            $line = $this->lines[$lineNo];\n            if ((bool) preg_match('/^@(\\w+)(\\s+(.*))?$/i', $line, $matches)) {\n                $this->tags[] = [\n                    'name' => $matches[1],\n                    'description' => isset($matches[3]) ? $matches[3] : '',\n                    'line' => $lineNo\n                ];\n                --$lineNo;\n            } else {\n                if (strlen($line) == 0) {\n                    --$lineNo;\n                } else {\n                    $continue = false;\n                }\n            }\n        } while ($lineNo > 0 && $continue == true);\n    }\n\n    private function extractDescription()\n    {\n        $startLine = 2;\n\n        if (count($this->tags)) {\n            $endLine = min(array_map(function ($t) { return $t['line']; }, $this->tags)) - 1;\n        } else {\n            $endLine = count($this->lines) - 1;\n        }\n\n        if ($endLine - $startLine > 0) {\n            $this->description = implode(\"\\n\", array_splice($this->lines, $startLine, $endLine - $startLine + 1));\n        }\n    }\n}\n"
  },
  {
    "path": "lib/cron.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Remove all scheduled cron jobs with this name.\n *\n * @param mixed $hook\n */\nfunction unschedule_events($hook)\n{\n    $crons = get_option('cron');\n\n    foreach ($crons as $time => $cron) {\n        if (isset($cron[$hook])) {\n            unset($crons[$time][$hook]);\n        }\n    }\n\n    update_option('cron', $crons);\n}\n"
  },
  {
    "path": "lib/custom_guid.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Ramsey\\Uuid\\Uuid as UUID;\n\n/**\n * Add custom GUID to episodes.\n * Display in all podcast feeds.\n */\nclass Custom_Guid\n{\n    /**\n     * Register hooks.\n     */\n    public static function init()\n    {\n        add_action('wp_insert_post', [__CLASS__, 'generate_guid_for_episodes'], 10, 2);\n        add_filter('get_the_guid', [__CLASS__, 'override_wordpress_guid'], 100, 2);\n        add_action('podlove_save_episode', [__CLASS__, 'save_form'], 10, 2);\n\n        add_action('add_meta_boxes_podcast', [__CLASS__, 'meta_box']);\n    }\n\n    public static function meta_box()\n    {\n        add_meta_box(\n            // $id\n            'podlove_podcast_guid',\n            // $title\n            __('Podcast Episode GUID', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            '\\Podlove\\Custom_Guid::meta_box_callback',\n            // $page\n            'podcast'\n        );\n    }\n\n    public static function meta_box_callback()\n    {\n        ?>\n\t\t<div>\n\t\t\t<span id=\"guid_preview\"><?php echo get_the_guid(); ?></span>\n\t\t\t<a href=\"#\" id=\"regenerate_guid\"><?php echo __('regenerate', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t</div>\n\t\t<span class=\"description\">\n\t\t\t<?php echo __('Identifier for this episode. Change it to force podcatchers to redownload media files for this episode.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</span>\n\n\t\t<input type=\"hidden\" name=\"_podlove_meta[guid]\" id=\"_podlove_meta_guid\" value=\"<?php echo get_the_guid(); ?>\">\n\n\t\t<script type=\"text/javascript\">\n\t\tjQuery(function($){\n\t\t\t$(\"#regenerate_guid\").on('click', function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\tvar data = {\n\t\t\t\t\taction: 'podlove-get-new-guid',\n\t\t\t\t\tpost_id: jQuery(\"#post_ID\").val()\n\t\t\t\t};\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: data,\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tif (result && result.guid) {\n\t\t\t\t\t\t\t$(\"#_podlove_meta_guid\").val(result.guid);\n\t\t\t\t\t\t\t$(\"#guid_preview\").html(result.guid);\n\t\t\t\t\t\t\tif ( ! $(\".guid_warning\").length ) {\n\t\t\t\t\t\t\t\t$(\".row__podlove_meta_guid .description\")\n\t\t\t\t\t\t\t\t\t.append(\"<br><strong class=\\\"guid_warning\\\">GUID regenerated. You still need to save the post.<br>Only regenerate if you messed up and need all clients to redownload all files!</strong>\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\talert(\"Sorry, couldn't generate new GUID.\");\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\treturn false;\n\t\t\t});\n\t\t});\n\t\t</script>\n\t\t<?php\n    }\n\n    public static function save_form($post_id, $form_data)\n    {\n        if (isset($form_data['guid'])) {\n            update_post_meta($post_id, '_podlove_guid', $form_data['guid']);\n        }\n    }\n\n    /**\n     * When an episode is created, generate and save a custom guid.\n     *\n     * @wp-hook wp_insert_post\n     *\n     * @param int    $post_id\n     * @param object $post\n     */\n    public static function generate_guid_for_episodes($post_id, $post)\n    {\n        if ($post->post_type !== 'podcast') {\n            return;\n        }\n\n        if (get_post_meta($post->ID, '_podlove_guid', true)) {\n            return;\n        }\n\n        $guid = self::guid_for_post($post);\n        update_post_meta($post->ID, '_podlove_guid', $guid);\n    }\n\n    /**\n     * Generate a guid for a WordPress post object.\n     *\n     * @param object $post\n     *\n     * @return string the GUID\n     */\n    public static function guid_for_post($post)\n    {\n        return apply_filters('podlove_guid', UUID::uuid4());\n    }\n\n    /**\n     * Whenever our GUID is available, use it. Fallback to WordPress GUID.\n     *\n     * @wp-hook get_the_guid\n     *\n     * @param string $guid    WordPress GUID\n     * @param mixed  $post_id\n     *\n     * @return string\n     */\n    public static function override_wordpress_guid($guid, $post_id = null)\n    {\n        if ($podlove_guid = get_post_meta($post_id, '_podlove_guid', true)) {\n            return $podlove_guid;\n        }\n\n        return $guid;\n    }\n\n    public static function find_duplicate_guids()\n    {\n        $published_post_ids = array_map(function ($e) {\n            return $e->post_id;\n        }, \\Podlove\\Model\\Podcast::get()->episodes());\n\n        $guids = [];\n\n        foreach ($published_post_ids as $post_id) {\n            $guid = get_the_guid($post_id);\n            if (!array_key_exists($guid, $guids)) {\n                $guids[$guid] = [$post_id];\n            } else {\n                $guids[$guid] = array_merge($guids[$guid], [$post_id]);\n            }\n        }\n\n        return array_filter($guids, function ($values) {\n            return count($values) > 1;\n        });\n    }\n}\n"
  },
  {
    "path": "lib/delete_head_requests.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * One-time migration that deletes HEAD entries from DownloadIntent table.\n *\n * Cannot be done through a normal migration since it takes too long.\n */\nclass DeleteHeadRequests\n{\n    public static function init()\n    {\n        if (!get_option('podlove_tracking_delete_head_requests')) {\n            return;\n        }\n\n        add_action('admin_notices', [__CLASS__, 'show_admin_notice']);\n        add_action('wp_ajax_podlove-tracking-delete-head-requests', [__CLASS__, 'ajax_delete']);\n    }\n\n    public static function ajax_delete()\n    {\n        global $wpdb;\n\n        $send_response = function ($todo) use ($wpdb) {\n            if (!$todo) {\n                // free disk space\n                $wpdb->query('OPTIMIZE TABLE '.\\Podlove\\Model\\DownloadIntent::table_name());\n                // clear caches\n                \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n                // mark migration as done\n                delete_option('podlove_tracking_delete_head_requests');\n            }\n\n            \\Podlove\\AJAX\\Ajax::respond_with_json(['todo' => $todo]);\n        };\n\n        // get user agent IDs to delete\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tid \n\t\t\tFROM\n\t\t\t\t'.\\Podlove\\Model\\UserAgent::table_name().' ua\n\t\t\tWHERE\n\t\t\t\tuser_agent LIKE \"libwww-perl/%\" \n\t\t\t\tOR user_agent LIKE \"curl/%\" \n\t\t\t\tOR user_agent LIKE \"PritTorrent/%\"\n\t\t';\n        $user_agent_ids = $wpdb->get_col($sql);\n\n        if (!count($user_agent_ids)) {\n            $send_response(0);\n        }\n\n        // delete\n        $sql = '\n\t\tDELETE\n\t\t\tFROM '.\\Podlove\\Model\\DownloadIntent::table_name().'\n\t\t\tWHERE user_agent_id IN ('.implode(',', $user_agent_ids).')\n\t\t\tLIMIT 25000\n\t\t';\n        $wpdb->query($sql);\n\n        $sql = '\n\t\tDELETE\n\t\t\tFROM '.\\Podlove\\Model\\DownloadIntentClean::table_name().'\n\t\t\tWHERE user_agent_id IN ('.implode(',', $user_agent_ids).')\n\t\t\tLIMIT 25000\n\t\t';\n        $wpdb->query($sql);\n\n        // see how much is left to delete\n        $sql = '\n\t\tSELECT\n\t\t\tCOUNT(*) \n\t\tFROM\n\t\t\t'.\\Podlove\\Model\\DownloadIntent::table_name().' \n\t\tWHERE\n\t\t\tuser_agent_id IN ('.implode(',', $user_agent_ids).')\n\t\t';\n\n        $send_response($wpdb->get_var($sql));\n    }\n\n    public static function show_admin_notice()\n    {\n        ?>\n\t\t<div class=\"update-nag\">\n\t\t\t<?php\n            echo __('To prepare for the Podlove Analytics release, your tracking database needs an update.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t<a id=\"podlove-start-tracking-migration\" href=\"#\"><?php echo __('Please update now'); ?></a>.\n\t\t\t<span id=\"podlove-migration-status\"></span>\n\t\t</div>\n\n\t\t<script type=\"text/javascript\">\n\t\tjQuery(function($){\n\n\t\t\tfunction delete_entries() {\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: {\n\t\t\t\t\t\taction: 'podlove-tracking-delete-head-requests'\n\t\t\t\t\t},\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tif (result.todo == \"0\") {\n\t\t\t\t\t\t\t$(\"#podlove-migration-status i\").hide();\n\t\t\t\t\t\t\t$(\"#podlove-migration-status .status\").html(\"Done!\");\n\t\t\t\t\t\t\t$(\"#podlove-migration-status\").parent(\".update-nag\").hide();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t$(\"#podlove-migration-status .status\").html(\"Rows to update: \" + result.todo);\n\t\t\t\t\t\t\tdelete_entries();\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t$(\"#podlove-start-tracking-migration\").on(\"click\", function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\t$(\"#podlove-migration-status\").html(\"<br><i class=\\\"podlove-icon-spinner rotate\\\"></i> <em class=\\\"status\\\">Waiting ...</span>\");\n\t\t\t\tdelete_entries();\n\t\t\t});\n\t\t});\n\t\t</script>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/dom_document_fragment.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Simple DOMDocument wrapper for fragments without <?xml ?> head.\n *\n * Example usage:\n *\n * \t$dom = new DomDocumentFragment;\n * \t$element = $dom->createElement('meta');\n * \t$dom->appendChild($element);\n * \techo $dom;\n */\nclass DomDocumentFragment extends \\DOMDocument\n{\n    public function __construct($version = '1.0', $encoding = 'UTF-8')\n    {\n        return parent::__construct($version, $encoding);\n    }\n\n    public function __toString()\n    {\n        return str_replace('<?xml version=\"1.0\" encoding=\"UTF-8\"?>', '', $this->saveXML());\n    }\n}\n"
  },
  {
    "path": "lib/downloads.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass Downloads\n{\n    /**\n     * Register hooks.\n     */\n    public static function init()\n    {\n        // Add \"Downloads\" column to episodes table\n        add_filter('manage_edit-podcast_columns', [__CLASS__, 'add_column_to_episodes_table']);\n        add_action('manage_podcast_posts_custom_column', [__CLASS__, 'add_column_content_to_episodes_table']);\n\n        /*\n         * This is probably how you add sortability.\n         * However, it requires a \"downloads\" meta entry.\n         * To make this work, a cron has to periodically (hourly?) update the downloads\n         * meta value.\n         *\n         *\tadd_filter('manage_edit-podcast_sortable_columns', function($columns) {\n         *\t\t$columns['downloads'] = 'downloads';\n         *\t\treturn $columns;\n         *\t});\n         *\n         *\tadd_action('pre_get_posts', function ($query) {\n         *\n         *\t    if (!is_admin())\n         *\t        return;\n         *\n         *\t    $orderby = $query->get('orderby');\n         *\n         *\t    if ('downloads' == $orderby) {\n         *\t        $query->set('meta_key', 'downloads');\n         *\t        $query->set('orderby', 'meta_value_num');\n         *\t    }\n         *\t});\n         *\n         */\n    }\n\n    public static function add_column_to_episodes_table($columns)\n    {\n        $keys = array_keys($columns);\n        $insertIndex = array_search('date', $keys) + 1; // after date column\n\n        // insert downloads at that index\n        return array_slice($columns, 0, $insertIndex, true)\n                   + ['downloads' => __('Downloads', 'podlove-podcasting-plugin-for-wordpress')]\n                   + array_slice($columns, $insertIndex, count($columns) - 1, true);\n    }\n\n    public static function add_column_content_to_episodes_table($column_name)\n    {\n        global $wpdb;\n\n        switch ($column_name) {\n            case 'downloads':\n                $total = get_post_meta(get_the_ID(), '_podlove_downloads_total', true);\n                if ($total && is_numeric($total)) {\n                    echo number_format_i18n($total);\n                }\n\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "lib/downloads_list_data.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass Downloads_List_Data\n{\n    public static function get_columns()\n    {\n        return [\n            // 'episode'   => __('Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'downloads' => __('Total', 'podlove-podcasting-plugin-for-wordpress'),\n            '3y' => __('3y', 'podlove-podcasting-plugin-for-wordpress'),\n            '2y' => __('2y', 'podlove-podcasting-plugin-for-wordpress'),\n            '1y' => __('1y', 'podlove-podcasting-plugin-for-wordpress'),\n            '3q' => __('3q', 'podlove-podcasting-plugin-for-wordpress'),\n            '2q' => __('2q', 'podlove-podcasting-plugin-for-wordpress'),\n            '1q' => __('1q', 'podlove-podcasting-plugin-for-wordpress'),\n            '4w' => __('4w', 'podlove-podcasting-plugin-for-wordpress'),\n            '3w' => __('3w', 'podlove-podcasting-plugin-for-wordpress'),\n            '2w' => __('2w', 'podlove-podcasting-plugin-for-wordpress'),\n            '1w' => __('1w', 'podlove-podcasting-plugin-for-wordpress'),\n            '6d' => __('6d', 'podlove-podcasting-plugin-for-wordpress'),\n            '5d' => __('5d', 'podlove-podcasting-plugin-for-wordpress'),\n            '4d' => __('4d', 'podlove-podcasting-plugin-for-wordpress'),\n            '3d' => __('3d', 'podlove-podcasting-plugin-for-wordpress'),\n            '2d' => __('2d', 'podlove-podcasting-plugin-for-wordpress'),\n            '1d' => __('1d', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public static function get_data($orderby = 'post_date', $order = 'desc')\n    {\n        $data = [];\n        foreach (Model\\Podcast::get()->episodes() as $episode) {\n            $post = $episode->post();\n\n            $data[] = [\n                'title' => $post->post_title,\n                'id' => (int) $episode->id,\n                'post_id' => (int) $episode->post_id,\n                'post_date' => $post->post_date,\n                'post_date_gmt' => $post->post_date_gmt,\n                'days_since_release' => $episode->days_since_release(),\n                'hours_since_release' => $episode->hours_since_release(),\n                'downloads' => get_post_meta($post->ID, '_podlove_downloads_total', true),\n                '3y' => get_post_meta($post->ID, '_podlove_downloads_3y', true),\n                '2y' => get_post_meta($post->ID, '_podlove_downloads_2y', true),\n                '1y' => get_post_meta($post->ID, '_podlove_downloads_1y', true),\n                '3q' => get_post_meta($post->ID, '_podlove_downloads_3q', true),\n                '2q' => get_post_meta($post->ID, '_podlove_downloads_2q', true),\n                '1q' => get_post_meta($post->ID, '_podlove_downloads_1q', true),\n                '4w' => get_post_meta($post->ID, '_podlove_downloads_4w', true),\n                '3w' => get_post_meta($post->ID, '_podlove_downloads_3w', true),\n                '2w' => get_post_meta($post->ID, '_podlove_downloads_2w', true),\n                '1w' => get_post_meta($post->ID, '_podlove_downloads_1w', true),\n                '6d' => get_post_meta($post->ID, '_podlove_downloads_6d', true),\n                '5d' => get_post_meta($post->ID, '_podlove_downloads_5d', true),\n                '4d' => get_post_meta($post->ID, '_podlove_downloads_4d', true),\n                '3d' => get_post_meta($post->ID, '_podlove_downloads_3d', true),\n                '2d' => get_post_meta($post->ID, '_podlove_downloads_2d', true),\n                '1d' => get_post_meta($post->ID, '_podlove_downloads_1d', true),\n            ];\n        }\n\n        $valid_order_keys = [\n            'post_date',\n            'downloads',\n        ];\n\n        // look for order options\n        if (isset($orderby) && in_array($orderby, $valid_order_keys)) {\n            $orderby = $orderby;\n        } else {\n            $orderby = 'post_date';\n        }\n\n        // look how to sort\n        if (isset($order)) {\n            $order = strtoupper($order) == 'ASC' ? SORT_ASC : SORT_DESC;\n        } else {\n            $order = SORT_DESC;\n        }\n\n        array_multisort(\n            \\array_column($data, $orderby),\n            $order,\n            $data\n        );\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "lib/downloads_list_table.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Jobs\\DownloadTimedAggregatorJob;\nuse Podlove\\Model\\Job;\n\nclass Downloads_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'download',   // singular name of the listed records\n            'plural' => 'downloads',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_episode($episode)\n    {\n        return sprintf(\n            '<a href=\"?page=%s&action=show&episode=%d\">%s</a> %s',\n            'podlove_analytics',\n            $episode['id'],\n            '<span class=\"dashicons dashicons-chart-bar\"></span> '.$episode['title'],\n            '<span style=\"color:#999; font-size: smaller\" title=\"'.esc_attr(mysql2date(get_option('date_format'), $episode['post_date'])).'\">'\n            .sprintf(__('%s ago'), human_time_diff(strtotime($episode['post_date_gmt'])))\n            .'</span>'\n        );\n    }\n\n    public function column_downloads($episode)\n    {\n        return self::get_number_or_dash($episode['downloads']);\n    }\n\n    public function column_cb($item)\n    {\n        $post_id = $item['post_id'];\n        $title = $item['title']; ?>\n\t\t<label class=\"screen-reader-text\" for=\"cb-select-<?php echo $post_id; ?>\"><?php\n            printf(__('Select %s'), $title); ?></label>\n\t\t<input id=\"cb-select-<?php echo $post_id; ?>\" type=\"checkbox\" name=\"post[]\" value=\"<?php echo $post_id; ?>\" />\n\t\t<?php\n    }\n\n    public function column_default($item, $column_name)\n    {\n        $aggregation_columns = self::aggregation_columns();\n\n        if (in_array($column_name, $aggregation_columns)) {\n            // completed aggregate number\n            if (is_numeric($item[$column_name]) && $item[$column_name]) {\n                return number_format_i18n($item[$column_name]);\n            }\n\n            // show grayed out total as temporary number\n            $group = DownloadTimedAggregatorJob::current_time_group($item);\n            if ($column_name == $group) {\n                return '<span style=\"color:#999;\">('.self::get_number_or_dash($item['downloads']).')</span>';\n            }\n\n            // otherwise a dash -\n            return '–';\n        }\n    }\n\n    public static function get_number_or_dash($value)\n    {\n        if (is_numeric($value) && $value) {\n            return number_format_i18n($value);\n        }\n\n        return '–';\n    }\n\n    public function get_columns()\n    {\n        return [\n            'cb' => '<input type=\"checkbox\" />',\n            // 'episode'   => __('Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'downloads' => __('Total', 'podlove-podcasting-plugin-for-wordpress'),\n            '3y' => __('3y', 'podlove-podcasting-plugin-for-wordpress'),\n            '2y' => __('2y', 'podlove-podcasting-plugin-for-wordpress'),\n            '1y' => __('1y', 'podlove-podcasting-plugin-for-wordpress'),\n            '3q' => __('3q', 'podlove-podcasting-plugin-for-wordpress'),\n            '2q' => __('2q', 'podlove-podcasting-plugin-for-wordpress'),\n            '1q' => __('1q', 'podlove-podcasting-plugin-for-wordpress'),\n            '4w' => __('4w', 'podlove-podcasting-plugin-for-wordpress'),\n            '3w' => __('3w', 'podlove-podcasting-plugin-for-wordpress'),\n            '2w' => __('2w', 'podlove-podcasting-plugin-for-wordpress'),\n            '1w' => __('1w', 'podlove-podcasting-plugin-for-wordpress'),\n            '6d' => __('6d', 'podlove-podcasting-plugin-for-wordpress'),\n            '5d' => __('5d', 'podlove-podcasting-plugin-for-wordpress'),\n            '4d' => __('4d', 'podlove-podcasting-plugin-for-wordpress'),\n            '3d' => __('3d', 'podlove-podcasting-plugin-for-wordpress'),\n            '2d' => __('2d', 'podlove-podcasting-plugin-for-wordpress'),\n            '1d' => __('1d', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function get_sortable_columns()\n    {\n        return [\n            'episode' => ['episode', true],\n            'downloads' => ['downloads', true],\n            '3y' => ['3y', true],\n            '2y' => ['2y', true],\n            '1y' => ['1y', true],\n            '3q' => ['3q', true],\n            '2q' => ['2q', true],\n            '1q' => ['1q', true],\n            '4w' => ['4w', true],\n            '3w' => ['3w', true],\n            '2w' => ['2w', true],\n            '1w' => ['1w', true],\n            '6d' => ['6d', true],\n            '5d' => ['5d', true],\n            '4d' => ['4d', true],\n            '3d' => ['3d', true],\n            '2d' => ['2d', true],\n            '1d' => ['1d', true],\n        ];\n    }\n\n    public static function aggregation_columns()\n    {\n        $columns = array_keys(DownloadTimedAggregatorJob::groupings());\n        array_shift($columns); // remove 'total' column\n\n        return $columns;\n    }\n\n    public function single_row($item)\n    {\n        $hidden_columns = count(get_hidden_columns(get_current_screen()));\n        $columns = count($this->get_columns()) - $hidden_columns;\n\n        echo '<tr>';\n        echo \"<td class=\\\"downloads-description\\\" colspan=\\\"{$columns}\\\">\";\n        echo $this->column_episode($item);\n        echo '</td>';\n        echo '</tr>';\n        echo '<tr>';\n        $this->single_row_columns($item);\n        echo '</tr>';\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = get_user_meta(get_current_user_id(), podlove_episodes_per_page_option_name(), true);\n        if (!$per_page) {\n            $per_page = 20;\n        }\n\n        // define column headers\n        $this->_column_headers = $this->get_column_info();\n\n        $data = Downloads_List_Data::get_data(\n            filter_input(INPUT_GET, 'orderby'),\n            filter_input(INPUT_GET, 'order')\n        );\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n\n    protected function extra_tablenav($which)\n    {\n        global $wpdb;\n\n        if ($which == 'bottom') { ?>\n\t\t\t<div class=\"alignleft actions\">\n\t\t\t\t<em><?php echo $this->data_age(); ?></em>\n\t\t\t</div>\n\n\t\t\t<script type=\"text/javascript\">\n\t\t\tjQuery(\"#adv-settings input[type=checkbox]\").on('change', function() {\n\t\t\t\tvar visibleCols = jQuery(\"#adv-settings input[type=checkbox]:checked\").length;\n\t\t\t\tjQuery(\"table.downloads td[colspan]\").attr('colspan', visibleCols);\n\t\t\t});\n\t\t\t</script>\n\t\t<?php\n        }\n\n        if ($which == 'top') { ?>\n\t\t  <div class=\"alignleft actions bulkactions\">\n\t\t\t\t<select name=\"action\" id=\"analytics-export-selector-top\">\n\t\t\t\t\t<option value=\"export-csv\">Export as CSV</option>\n\t\t\t\t\t<option value=\"export-json\">Export as JSON</option>\n\t\t\t\t</select>\n\t\t\t\t<input type=\"submit\" class=\"button action\" value=\"Export\">\n\t\t\t</div>\n\t\t<?php\n        }\n    }\n\n    private function data_age()\n    {\n        global $wpdb;\n\n        $get_cron_info = function ($cron_name) {\n            $next_cron = wp_next_scheduled($cron_name);\n            $schedules = wp_get_schedules();\n            $offset = wp_get_schedule($cron_name);\n            if (isset($schedules[$offset])) {\n                $interval = $schedules[$offset]['interval'];\n            } else {\n                $interval = 0;\n            }\n            $prev_cron = $next_cron - $interval;\n\n            return [\n                'interval' => $interval,\n                'next' => $next_cron,\n                'prev' => $prev_cron,\n            ];\n        };\n\n        $totals_cron = $get_cron_info('podlove_calc_hourly_download_sums');\n        $prev_totals_job = Job::find_one_recent_finished_job('Podlove\\Jobs\\DownloadTimedAggregatorJob');\n\n        echo sprintf(\n            __('Analytics data is %s old.', 'podlove-podcasting-plugin-for-wordpress'),\n            human_time_diff(max($totals_cron['prev'], strtotime($prev_totals_job->updated_at)), time())\n        );\n        echo ' ';\n        echo sprintf(\n            __('Next update will be in %s.', 'podlove-podcasting-plugin-for-wordpress'),\n            human_time_diff(time(), $totals_cron['next'])\n        );\n    }\n}\n"
  },
  {
    "path": "lib/duplicate_post.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\n\nclass DuplicatePost\n{\n    public static function init()\n    {\n        add_action('dp_duplicate_post', [__CLASS__, 'regenerate_guid'], 100, 2);\n        add_filter('duplicate_post_meta_keys_filter', [__CLASS__, 'meta_keys_filter']);\n\n        if (\\Podlove\\Modules\\Base::is_active('contributors')) {\n            add_action('dp_duplicate_post', [__CLASS__, 'clone_contributors'], 10, 2);\n        }\n    }\n\n    public static function meta_keys_filter($keys)\n    {\n        return array_filter($keys, function ($key) {\n            return stripos($key, '_podlove_downloads') === false\n            && stripos($key, '_podlove_notifications_sent') === false\n            && stripos($key, '_podlove_guid') === false\n            && stripos($key, '_podlove_eda_downloads') === false;\n        });\n    }\n\n    public static function regenerate_guid($new_post_id, $old_post_object)\n    {\n        delete_post_meta($new_post_id, '_podlove_guid');\n        \\Podlove\\Custom_Guid::generate_guid_for_episodes($new_post_id, get_post($new_post_id));\n    }\n\n    public static function clone_contributors($new_post_id, $old_post_object)\n    {\n        $old_episode = Episode::find_one_by_post_id($old_post_object->ID);\n        $new_episode = Episode::find_or_create_by_post_id($new_post_id);\n        $old_contributions = EpisodeContribution::find_all_by_episode_id($old_episode->id);\n\n        foreach ($old_contributions as $old_contribution) {\n            $c = new EpisodeContribution();\n            $c->contributor_id = $old_contribution->contributor_id;\n            $c->episode_id = $new_episode->id;\n            $c->role_id = $old_contribution->role_id;\n            $c->group_id = $old_contribution->group_id;\n            $c->position = $old_contribution->position;\n            $c->comment = $old_contribution->comment;\n            $c->save();\n        }\n    }\n}\n"
  },
  {
    "path": "lib/duration.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Helper class to manage duration string.\n *\n * @see http://podlove.org/simple-chapters/#Time\n */\nclass Duration\n{\n    /**\n     * Raw user input.\n     *\n     * @var string\n     */\n    private $duration;\n\n    /* int */ private $hours;\n    /* int */ private $minutes;\n    /* int */ private $seconds;\n    /* int */ private $milliseconds;\n\n    /* bool */ private $valid = true;\n\n    public function __construct($duration)\n    {\n        $this->duration = trim($duration ?? '');\n        $this->normalize();\n    }\n\n    /**\n     * Get duration in a certain format.\n     *\n     * @param string $format (optional) Time format.\n     *                       Possibilities: full, HH:MM:SS, hours, minutes, seconds, milliseconds.\n     *                       Default: full\n     *\n     * @return string\n     */\n    public function get($format = 'full')\n    {\n        if (!$this->valid) {\n            switch ($format) {\n                case 'HH:MM:SS':\n                    return '00:00:00';\n\n                    break;\n                case 'full': // full is default\n                default:\n                    return '00:00:00.000';\n\n                    break;\n            }\n        }\n\n        switch ($format) {\n            case 'hours':\n                return $this->hours;\n\n                break;\n            case 'minutes':\n                return $this->minutes;\n\n                break;\n            case 'seconds':\n                return $this->seconds;\n\n                break;\n            case 'milliseconds':\n                return $this->milliseconds;\n\n                break;\n            case 'HH:MM:SS':\n                return $this->format(true, true, true, false);\n\n                break;\n            case 'human-readable':\n                $duration_string = '';\n\n                if ($this->hours > 1) {\n                    $duration_string .= $this->hours.__(' hours ', 'podlove-podcasting-plugin-for-wordpress');\n                } elseif ($this->hours == 1) {\n                    $duration_string .= $this->hours.__(' hour ', 'podlove-podcasting-plugin-for-wordpress');\n                }\n\n                if ($this->minutes >= 1) {\n                    $duration_string .= $this->minutes.__(' minutes ', 'podlove-podcasting-plugin-for-wordpress');\n                }\n\n                if ($this->hours == 0 && $this->minutes == 0) {\n                    $duration_string .= $this->seconds.__(' seconds', 'podlove-podcasting-plugin-for-wordpress');\n                }\n\n                return $duration_string;\n\n                break;\n            case 'full': // full is default\n            default:\n                return $this->format();\n\n                break;\n        }\n    }\n\n    /**\n     * Get duration specifying the required time segments.\n     *\n     * @param bool $hours\n     * @param bool $minutes\n     * @param bool $seconds\n     * @param bool $milliseconds\n     *\n     * @return string\n     */\n    public function format($hours = true, $minutes = true, $seconds = true, $milliseconds = true)\n    {\n        $duration = '';\n\n        if ($hours) {\n            $duration .= lfill($this->hours, 2, '0').':';\n        }\n\n        if ($minutes) {\n            $duration .= lfill($this->minutes, 2, '0').':';\n        }\n\n        if ($seconds) {\n            $duration .= lfill($this->seconds, 2, '0');\n        }\n\n        if ($milliseconds) {\n            $duration .= '.'.rfill($this->milliseconds, 3, '0');\n        }\n\n        return $duration;\n    }\n\n    /**\n     * Extract time segments from duration string.\n     *\n     * - verifies validity\n     * - extracts hours, minutes, seconds, milliseconds\n     */\n    private function normalize()\n    {\n        if ($milliseconds = \\Podlove\\NormalPlayTime\\Parser::parse($this->duration, 'ms')) {\n            $this->hours = floor((($milliseconds / 1000) / 60) / 60);\n            $this->minutes = floor(($milliseconds / 1000) / 60) % 60;\n            $this->seconds = floor($milliseconds / 1000) % 60;\n            $this->milliseconds = $milliseconds % 1000;\n        } else {\n            $this->valid = false;\n        }\n    }\n}\n\n/**\n * Append characters to the right of the given string until a length is reached.\n *\n * @param string $string\n * @param int    $length\n * @param string $fillchar\n *\n * @return string\n */\nfunction rfill($string, $length, $fillchar = ' ')\n{\n    while (strlen($string) < $length) {\n        $string .= $fillchar;\n    }\n\n    return $string;\n}\n\n/**\n * Append characters to the left of the given string until a length is reached.\n *\n * @param string $string\n * @param int    $length\n * @param string $fillchar\n *\n * @return string\n */\nfunction lfill($string, $length, $fillchar = ' ')\n{\n    while (strlen($string) < $length) {\n        $string = $fillchar.$string;\n    }\n\n    return $string;\n}\n"
  },
  {
    "path": "lib/episode_asset_list_table.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass Episode_Asset_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'episode_asset',   // singular name of the listed records\n            'plural' => 'episode_assets',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_title($episode_asset)\n    {\n        $link = function ($title, $action = 'edit') use ($episode_asset) {\n            return sprintf(\n                '<a href=\"?page=%s&action=%s&episode_asset=%s&_podlove_nonce=%s\">'.$title.'</a>',\n                Settings\\EpisodeAsset::MENU_SLUG,\n                $action,\n                $episode_asset->id,\n                wp_create_nonce('update_assets')\n            ).'<input type=\"hidden\" class=\"position\" value=\"'.$episode_asset->position.'\">'\n              .'<input type=\"hidden\" class=\"asset_id\" value=\"'.$episode_asset->id.'\">';\n        };\n\n        $actions = [\n            'edit' => $link(__('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'batch_enable' => $link(__('Activate for all existing Episodes', 'podlove-podcasting-plugin-for-wordpress'), 'batch_enable'),\n            'delete' => $link(__('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'delete'),\n        ];\n\n        $title = $episode_asset->title ? esc_html($episode_asset->title) : __('- title missing -', 'podlove-podcasting-plugin-for-wordpress');\n\n        return sprintf(\n            '%1$s %2$s',\n            $link($title),\n            $this->row_actions($actions)\n        );\n    }\n\n    public function column_file_type($episode_asset)\n    {\n        $format = $episode_asset->file_type();\n\n        return ($format) ? $format->title() : '-';\n    }\n\n    public function column_downloadable($episode_asset)\n    {\n        return $episode_asset->downloadable ? '✓' : '×';\n    }\n\n    public function column_move($episode_asset)\n    {\n        return '<i class=\"reorder-handle podlove-icon-reorder\"></i>';\n    }\n\n    public function get_columns()\n    {\n        return [\n            'title' => __('Episode Asset', 'podlove-podcasting-plugin-for-wordpress'),\n            'file_type' => __('File Type', 'podlove-podcasting-plugin-for-wordpress'),\n            'downloadable' => __('Downloadable', 'podlove-podcasting-plugin-for-wordpress'),\n            'move' => '',\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = 100;\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        $data = \\Podlove\\Model\\EpisodeAsset::all('ORDER BY position ASC');\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/feed_list_table.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Modules\\Plus\\FeedProxy;\n\nclass Feed_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'feed',   // singular name of the listed records\n            'plural' => 'feeds',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_name($feed)\n    {\n        $actions = [\n            'edit' => Settings\\Feed::get_action_link($feed, __('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => Settings\\Feed::get_action_link($feed, __('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            Settings\\Feed::get_action_link($feed, $feed->name),\n            $this->row_actions($actions)\n        ).'<input type=\"hidden\" class=\"position\" value=\"'.$feed->position.'\">'\n          .'<input type=\"hidden\" class=\"feed_id\" value=\"'.$feed->id.'\">';\n    }\n\n    public function column_limit($feed)\n    {\n        $podlove_feed_limit = \\Podlove\\Model\\Podcast::get()->limit_items;\n        switch ($feed->limit_items) {\n            case '0':\n                return get_option('posts_per_rss').' (WordPress default)';\n\n                break;\n            case '-1':\n                return 'unlimited';\n\n                break;\n            case '-2':\n                return ($podlove_feed_limit == '-1' ? 'unlimited' : ($podlove_feed_limit == '0' ? get_option('posts_per_rss').' (WordPress default)' : $podlove_feed_limit))\n                       .' (global default)';\n\n                break;\n\n            default:\n                return $feed->limit_items;\n\n                break;\n        }\n    }\n\n    public function column_discoverable($feed)\n    {\n        return $feed->discoverable ? '✓' : '×';\n    }\n\n    public function column_protected($feed)\n    {\n        return $feed->protected ? '✓' : '×';\n    }\n\n    public function column_url($feed)\n    {\n        $link = $feed->get_subscribe_link();\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        if (!FeedProxy::is_enabled()) {\n            if ($feed->redirect_http_status > 0 && strlen($feed->redirect_url)) {\n                $link .= \"<br><span title=\\\"redirects to\\\">&#8618;</span>&nbsp;<a target=\\\"_blank\\\" href=\\\"{$feed->redirect_url}\\\">{$feed->redirect_url}</a>\";\n            }\n        } else {\n            $link = apply_filters('podlove_feed_table_url', $link, $feed);\n        }\n\n        return $link;\n    }\n\n    public function column_media($feed)\n    {\n        $episode_asset = $feed->episode_asset();\n\n        return ($episode_asset) ? $episode_asset->title() : __('not set', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public function column_move($feed)\n    {\n        return '<i class=\"reorder-handle podlove-icon-reorder\"></i>';\n    }\n\n    public function get_columns()\n    {\n        $columns = [\n            'name' => __('Feed', 'podlove-podcasting-plugin-for-wordpress'),\n            'url' => __('Subscribe URL', 'podlove-podcasting-plugin-for-wordpress'),\n            'media' => __('Media', 'podlove-podcasting-plugin-for-wordpress'),\n            'limit' => __('Item Limit', 'podlove-podcasting-plugin-for-wordpress'),\n            'discoverable' => __('Discoverable', 'podlove-podcasting-plugin-for-wordpress'),\n            'move' => '',\n        ];\n\n        return apply_filters('podlove_feed_list_table_columns', $columns);\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = get_user_meta(get_current_user_id(), 'podlove_feeds_per_page', true);\n        if (empty($per_page)) {\n            $per_page = 10;\n        }\n\n        // define column headers\n        $this->_column_headers = $this->get_column_info();\n\n        // retrieve data\n        $data = \\Podlove\\Model\\Feed::all('ORDER BY position ASC');\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/feeds/base.php",
    "content": "<?php\n\nnamespace Podlove\\Feeds;\n\nuse Podlove\\Model;\n\nfunction get_description()\n{\n    global $post;\n\n    $episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post->ID);\n\n    $summary = trim($episode->summary ?? '');\n    $subtitle = trim($episode->subtitle ?? '');\n    $title = trim($post->post_title ?? '');\n\n    $description = $title;\n\n    if (strlen($summary) > 0) {\n        $description = $summary;\n    } elseif (strlen($subtitle) > 0) {\n        $description = $subtitle;\n    }\n\n    return apply_filters('podlove_feed_item_description', html_entity_decode($description));\n}\n\nfunction override_feed_title($feed)\n{\n    add_filter('podlove_feed_title', function ($title) use ($feed) {\n        return htmlspecialchars($feed->get_title() ?? '');\n    });\n}\n\nfunction override_feed_description($feed)\n{\n    add_filter('podlove_rss_feed_description', function ($description) {\n        $podcast = Model\\Podcast::get();\n\n        if ($podcast->subtitle) {\n            $desc = $podcast->subtitle;\n        } elseif ($podcast->summary) {\n            $desc = $podcast->summary;\n        } else {\n            $desc = $description;\n        }\n\n        return get_xml_cdata_text($desc);\n    });\n}\n\nfunction override_feed_language($feed)\n{\n    add_filter('pre_option_rss_language', function ($language) {\n        $podcast = Model\\Podcast::get();\n\n        return apply_filters('podlove_feed_language', ($podcast->language) ? $podcast->language : $language);\n    });\n}\n\nfunction get_episode_title($post = 0)\n{\n    $post = get_post($post);\n    $title = $post->post_title ?? '';\n\n    return apply_filters('podlove_get_episode_title_rss', $title);\n}\n\n/**\n * Prepare content for display in feed.\n *\n * - Trim whitespace\n * - Convert special characters to HTML entities\n *\n * @param string $content\n *\n * @return string\n */\nfunction prepare_for_feed($content)\n{\n    return trim(htmlspecialchars($content));\n}\n\nfunction strip_style_tags_from_feed_content($content)\n{\n    return preg_replace('#<style(.*?)>(.*?)</style>#is', '', $content);\n}\n\nfunction get_optimized_content_encoded_allowed_html()\n{\n    $allowed_html = wp_kses_allowed_html('post');\n    $allowed_html = array_map(function () {\n        return [];\n    }, $allowed_html);\n\n    $allowed_html['a'] = ['href' => true];\n    $allowed_html['blockquote'] = ['cite' => true];\n    $allowed_html['img'] = [\n        'alt' => true,\n        'height' => true,\n        'src' => true,\n        'title' => true,\n        'width' => true,\n    ];\n    $allowed_html['q'] = ['cite' => true];\n\n    return apply_filters('podlove_feed_optimized_content_encoded_allowed_html', $allowed_html);\n}\n\nfunction prepare_content_encoded($content, $optimize = false)\n{\n    $content = strip_style_tags_from_feed_content($content);\n\n    if (!$optimize) {\n        return $content;\n    }\n\n    return wp_kses($content, get_optimized_content_encoded_allowed_html());\n}\n\nfunction get_xml_text_node($tag_name, $content)\n{\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement($tag_name);\n    $text = $doc->createTextNode($content ?? '');\n    $node->appendChild($text);\n\n    return $doc->saveXML($node);\n}\n\nfunction get_xml_cdata_node($tag_name, $content)\n{\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement($tag_name);\n    $text = $doc->createCDATASection($content ?? '');\n    $node->appendChild($text);\n\n    return $doc->saveXML($node);\n}\n\nfunction get_xml_cdata_text($content)\n{\n    $doc = new \\DOMDocument();\n    $text = $doc->createCDATASection($content);\n\n    return $doc->saveXML($text);\n}\n\nfunction get_xml_itunesimage_node($url)\n{\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement('itunes:image');\n\n    $attr = $doc->createAttribute('href');\n\n    // unexpected but true: ampersands are not escaped automatically here\n    $attr->value = esc_attr($url);\n\n    $node->appendChild($attr);\n\n    return $doc->saveXML($node);\n}\n\nfunction get_xml_podcast_funding_node($url, $label)\n{\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement('podcast:funding');\n    $text = $doc->createTextNode($label);\n    $node->appendChild($text);\n\n    $attr = $doc->createAttribute('url');\n\n    // unexpected but true: ampersands are not escaped automatically here\n    $attr->value = esc_attr($url);\n\n    $node->appendChild($attr);\n\n    return $doc->saveXML($node);\n}\n\nfunction get_xml_podcast_license_node($identifier, $url)\n{\n    $doc = new \\DOMDocument();\n    $node = $doc->createElement('podcast:license');\n    $text = $doc->createTextNode($identifier);\n    $node->appendChild($text);\n\n    if ($url) {\n        $attr = $doc->createAttribute('url');\n        $attr->value = esc_attr($url);\n\n        $node->appendChild($attr);\n    }\n\n    return $doc->saveXML($node);\n}\n\nfunction add_itunes_category($category_html, $categories, $category_id)\n{\n    $category_id = apply_filters('podlove_feed_itunes_category_id', $category_id);\n\n    if ($category_id) {\n        list($cat, $subcat) = explode('-', $category_id);\n\n        if ($subcat == '00') {\n            $category_html .= sprintf(\n                '<itunes:category text=\"%s\" />%s',\n                htmlspecialchars($categories[$category_id]),\n                PHP_EOL\n            );\n        } else {\n            if (isset($categories[$category_id])) {\n                $category_html .= sprintf(\n                    '<itunes:category text=\"%s\"><itunes:category text=\"%s\"></itunes:category></itunes:category>%s',\n                    htmlspecialchars($categories[$cat.'-00']),\n                    htmlspecialchars($categories[$category_id]),\n                    PHP_EOL\n                );\n            } else {\n                $category_html .= sprintf(\n                    '<itunes:category text=\"%s\" />%s',\n                    htmlspecialchars($categories[$cat.'-00']),\n                    PHP_EOL\n                );\n            }\n        }\n    }\n\n    return $category_html;\n}\n\nfunction override_feed_head($hook, $podcast, $feed, $format)\n{\n    add_filter('podlove_feed_content', '\\Podlove\\Feeds\\prepare_for_feed');\n\n    remove_action($hook, 'the_generator');\n    add_action($hook, function () use ($hook) {\n        switch ($hook) {\n            case 'rss2_head':\n                $gen = '<generator>'.\\Podlove\\get_plugin_header('Name').' v'.\\Podlove\\get_plugin_header('Version').'</generator>';\n\n                break;\n            case 'atom_head':\n                $gen = '<generator uri=\"'.\\Podlove\\get_plugin_header('PluginURI').'\" version=\"'.\\Podlove\\get_plugin_header('Version').'\">'.\\Podlove\\get_plugin_header('Name').'</generator>';\n\n                break;\n        }\n        echo $gen;\n    });\n\n    add_action($hook, function () use ($feed) {\n        echo $feed->get_feed_self_link();\n        echo $feed->get_alternate_links();\n    }, 9);\n\n    // add rss image\n    add_action($hook, function () use ($podcast, $hook) {\n        $image = [\n            'url' => apply_filters('podlove_feed_itunes_image_url', $podcast->cover_art()->url()),\n            'title' => $podcast->title,\n            'link' => apply_filters('podlove_feed_link', \\Podlove\\get_landing_page_url()),\n        ];\n        $image = apply_filters('podlove_feed_image', $image);\n\n        if (!$image['url']) {\n            return;\n        }\n\n        // remove WordPress provided favicon\n        remove_action($hook, 'rss2_site_icon');\n\n        // generate our own image tag\n        $dom = new \\Podlove\\DomDocumentFragment();\n        $image_tag = $dom->createElement('image');\n\n        foreach ($image as $tag_name => $tag_text) {\n            if ($tag_text) {\n                $tag = $dom->createElement($tag_name);\n                $tag_text = $dom->createTextNode($tag_text);\n                $tag->appendChild($tag_text);\n                $image_tag->appendChild($tag);\n            }\n        }\n\n        $dom->appendChild($image_tag);\n\n        echo (string) $dom;\n    }, 5); // let it run early so we can stop the `rss2_site_icon` call\n\n    add_action($hook, function () use ($podcast, $feed, $format) {\n        echo PHP_EOL;\n\n        if ($podcast->guid) {\n            $guid = \"\\t\".get_xml_text_node('podcast:guid', $podcast->guid);\n            echo apply_filters('podlove_feed_guid', $guid);\n            echo PHP_EOL;\n        }\n\n        $copyright = \"\\t\".get_xml_text_node('copyright', $podcast->copyright ?? $podcast->default_copyright_claim());\n        echo apply_filters('podlove_feed_copyright', $copyright);\n        echo PHP_EOL;\n\n        $author = \"\\t\".get_xml_text_node('itunes:author', $podcast->author_name);\n        echo apply_filters('podlove_feed_itunes_author', $author);\n        echo PHP_EOL;\n\n        $type = in_array($podcast->itunes_type, ['episodic', 'serial']) ? $podcast->itunes_type : 'episodic';\n        $type = \"\\t\".get_xml_text_node('itunes:type', $type);\n        echo apply_filters('podlove_feed_itunes_type', $type);\n        echo PHP_EOL;\n\n        $summary = \"\\t\".get_xml_cdata_node('itunes:summary', $podcast->summary);\n        echo apply_filters('podlove_feed_itunes_summary', $summary);\n        echo PHP_EOL;\n\n        $categories = \\Podlove\\Itunes\\categories(false);\n\n        $category_html = '';\n        $category_html = add_itunes_category($category_html, $categories, $podcast->category_1);\n        $category_html = add_itunes_category($category_html, $categories, $podcast->category_2);\n        $category_html = add_itunes_category($category_html, $categories, $podcast->category_3);\n\n        echo apply_filters('podlove_feed_itunes_categories', $category_html);\n        echo PHP_EOL;\n\n        $owner = '\n\t<itunes:owner>\n\t\t'.get_xml_text_node('itunes:name', $podcast->owner_name).'\n\t\t'.get_xml_text_node('itunes:email', $podcast->owner_email).'\n\t</itunes:owner>';\n        echo \"\\t\".apply_filters('podlove_feed_itunes_owner', $owner);\n        echo PHP_EOL;\n\n        if ($cover_art_url = $podcast->cover_art()->url()) {\n            $coverimage = get_xml_itunesimage_node($cover_art_url);\n        } else {\n            $coverimage = '';\n        }\n        echo \"\\t\".apply_filters('podlove_feed_itunes_image', $coverimage);\n        echo PHP_EOL;\n\n        $subtitle = get_xml_text_node('itunes:subtitle', $podcast->subtitle);\n        echo \"\\t\".apply_filters('podlove_feed_itunes_subtitle', $subtitle);\n        echo PHP_EOL;\n\n        $block = sprintf('<itunes:block>%s</itunes:block>', ($feed->enable) ? 'no' : 'yes');\n        echo \"\\t\".apply_filters('podlove_feed_itunes_block', $block);\n        echo PHP_EOL;\n\n        $explicit = sprintf('<itunes:explicit>%s</itunes:explicit>', $podcast->explicit_text());\n        echo \"\\t\".apply_filters('podlove_feed_itunes_explicit', $explicit);\n        echo PHP_EOL;\n\n        $complete = sprintf('<itunes:complete>%s</itunes:complete>', ($podcast->complete) ? 'yes' : 'no');\n        echo \"\\t\".apply_filters('podlove_feed_itunes_complete', $podcast->complete ? \"\\t{$complete}\" : '');\n        echo PHP_EOL;\n\n        $itunes_feed_id = (int) $feed->itunes_feed_id;\n        if ($itunes_feed_id > 0) {\n            $link_apple = sprintf('<atom:link rel=\"me\" href=\"https://podcasts.apple.com/podcast/id%s\" />', $itunes_feed_id);\n            echo \"\\t\".apply_filters('podlove_feed_link_apple', $link_apple);\n            echo PHP_EOL;\n        }\n\n        if ($podcast->funding_url) {\n            echo \"\\t\".get_xml_podcast_funding_node($podcast->funding_url, $podcast->funding_label);\n            echo PHP_EOL;\n        }\n\n        if ($podcast->license_name) {\n            $license = $podcast->get_license();\n            echo \"\\t\".get_xml_podcast_license_node($license->getIdentifier(), $podcast->license_url);\n            echo PHP_EOL;\n        }\n\n        do_action('podlove_append_to_feed_head', $podcast, $feed, $format);\n    });\n}\n\nfunction override_feed_entry($hook, $podcast, $feed, $format)\n{\n    add_action($hook, function () use ($podcast, $feed, $format) {\n        global $post;\n        global $wp_query;\n\n        $cache_key = 'feed_item_'.$feed->slug.'_'.$post->ID;\n\n        // if this is a show feed, add the show slug to the cache key\n        if ($wp_query->tax_query->queries) {\n            $q = $wp_query->tax_query->queries[0];\n            if ($q['taxonomy'] == 'shows') {\n                $terms = $q['terms'];\n                $slug = $terms[0];\n                if ($slug) {\n                    $cache_key .= '_'.$slug;\n                }\n            }\n        }\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        echo $cache->cache_for($cache_key, function () use ($podcast, $feed, $format, $post) {\n            $xml = '';\n\n            $episode = Model\\Episode::find_one_by_post_id($post->ID);\n            $asset = $feed->episode_asset();\n            $file = Model\\MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset->id);\n            $asset_assignment = Model\\AssetAssignment::get_instance();\n\n            if (!$file) {\n                return;\n            }\n\n            $enclosure_file_size = $file->size;\n\n            $cover_art_url = '';\n            if ($cover_art = $episode->cover_art()) {\n                $cover_art_url = $cover_art->url();\n            }\n\n            if (isset($_REQUEST['tracking']) && $_REQUEST['tracking'] == 'no') {\n                $enclosure_url = $episode->enclosure_url($feed->episode_asset(), null, null);\n            } else {\n                $enclosure_url = $episode->enclosure_url($feed->episode_asset(), 'feed', $feed->slug);\n            }\n\n            $tag_prefix = \"\\n\\t\\t\";\n\n            $deep_link = Model\\Feed::get_link_tag([\n                'prefix' => 'atom',\n                'rel' => 'http://podlove.org/deep-link',\n                'type' => '',\n                'title' => '',\n                'href' => get_permalink().'#',\n            ]);\n            $xml .= $tag_prefix.apply_filters('podlove_deep_link', $deep_link, $feed);\n\n            $xml .= $tag_prefix.apply_filters('podlove_feed_enclosure', '', $enclosure_url, $enclosure_file_size, $format->mime_type, $file);\n\n            $duration = sprintf('<itunes:duration>%s</itunes:duration>', $episode->get_duration('HH:MM:SS'));\n            $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_duration', $duration);\n\n            $author = apply_filters('podlove_feed_content', $podcast->author_name);\n            $author = get_xml_text_node('itunes:author', $author);\n            $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_author', $author);\n\n            $subtitle = apply_filters('podlove_feed_content', \\Podlove\\PHP\\escape_shortcodes($episode->subtitle));\n            $subtitle = get_xml_text_node('itunes:subtitle', $subtitle);\n            $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_subtitle', $subtitle);\n\n            if ($episode->title) {\n                $title = apply_filters('podlove_feed_itunes_title', $episode->title);\n                $title = get_xml_text_node('itunes:title', $title);\n                $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_title_xml', $title);\n            }\n\n            if (is_numeric($episode->number)) {\n                $number = apply_filters('podlove_feed_itunes_episode', (int) $episode->number);\n                $number = sprintf('<itunes:episode>%s</itunes:episode>', $number);\n                $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_episode_xml', $number);\n            }\n\n            $type = in_array($episode->type, ['full', 'trailer', 'bonus']) ? $episode->type : 'full';\n            $type = apply_filters('podlove_feed_itunes_type', $type);\n            $type = sprintf('<itunes:episodeType>%s</itunes:episodeType>', $type);\n            $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_type_xml', $type);\n\n            $summary = apply_filters('podlove_feed_content', \\Podlove\\PHP\\escape_shortcodes(html_entity_decode($episode->summary ?? '')));\n            if (strlen($summary)) {\n                $summary = get_xml_cdata_node('itunes:summary', $summary);\n            }\n            $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_episode_summary', $summary);\n\n            if (\\Podlove\\get_setting('metadata', 'enable_episode_explicit')) {\n                $itunes_explicit = apply_filters('podlove_feed_content', $episode->explicit_text());\n                $itunes_explicit = sprintf('<itunes:explicit>%s</itunes:explicit>', $itunes_explicit);\n                $xml .= $tag_prefix.apply_filters('podlove_feed_itunes_explicit', $itunes_explicit);\n            }\n\n            if ($cover_art_url) {\n                $cover_art = get_xml_itunesimage_node($cover_art_url);\n            } else {\n                $cover_art = '';\n            }\n            $xml .= $tag_prefix.apply_filters('podlove_feed_episode_itunes_image', $cover_art);\n\n            if ($feed->embed_content_encoded) {\n                $content_encoded = prepare_content_encoded(\n                    get_the_content_feed('rss2'),\n                    (bool) $feed->optimize_content_encoded_html\n                );\n                $content_encoded = get_xml_cdata_node('content:encoded', $content_encoded);\n                $xml .= $tag_prefix.apply_filters('podlove_feed_content_encoded', $content_encoded);\n            }\n\n            ob_start();\n            do_action('podlove_append_to_feed_entry', $podcast, $episode, $feed, $format);\n            $xml .= ob_get_contents();\n            ob_end_clean();\n\n            return $xml;\n        }, 15 * MINUTE_IN_SECONDS);\n    }, 11);\n}\n"
  },
  {
    "path": "lib/feeds/chapters.php",
    "content": "<?php\n\nnamespace Podlove\\Feeds;\n\nuse Podlove\\Model;\n\n/**\n * Embed chapters into feed.\n */\nclass Chapters\n{\n    private $episode;\n\n    public function __construct(Model\\Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    /**\n     * Render chapters into feed.\n     *\n     * @param string $style 'inline' or 'link'. Default: link\n     */\n    public function render($style = 'link')\n    {\n        $this->{'render_'.$style}();\n    }\n\n    public function render_inline()\n    {\n        echo $this->episode->get_chapters('psc');\n    }\n\n    public function render_link()\n    {\n        echo Model\\Feed::get_link_tag([\n            'prefix' => 'atom',\n            'rel' => 'http://podlove.org/simple-chapters',\n            'type' => '',\n            'title' => '',\n            'href' => get_permalink().'?chapters_format=psc',\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/feeds/rss.php",
    "content": "<?php\n\nnamespace Podlove\\Feeds;\n\nuse Podlove\\Model;\n\nrequire_once \\Podlove\\PLUGIN_DIR.'/lib/feeds/base.php';\n\nclass RSS\n{\n    public static function prepare_feed($feed_slug)\n    {\n        global $wp_query;\n\n        add_action('rss2_ns', function () {\n            echo 'xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" ';\n            echo 'xmlns:psc=\"http://podlove.org/simple-chapters\" ';\n            echo 'xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" ';\n            echo 'xmlns:fh=\"http://purl.org/syndication/history/1.0\" ';\n            echo 'xmlns:podcast=\"https://podcastindex.org/namespace/1.0\" ';\n        });\n\n        $podcast = Model\\Podcast::get();\n\n        if (!$feed = Model\\Feed::find_one_by_slug($feed_slug)) {\n            self::wp_404();\n        }\n\n        if (!$episode_asset = $feed->episode_asset()) {\n            self::wp_404();\n        }\n\n        $file_type = $episode_asset->file_type();\n\n        add_filter('podlove_feed_enclosure', function ($enclosure, $enclosure_url, $enclosure_file_size, $mime_type, $media_file) {\n            if ($enclosure_file_size < 0) {\n                $enclosure_file_size = 0;\n            }\n\n            $dom = new \\Podlove\\DomDocumentFragment();\n            $element = $dom->createElement('enclosure');\n\n            $attributes = [\n                'url' => $enclosure_url,\n                'length' => $enclosure_file_size,\n                'type' => $mime_type,\n            ];\n\n            $attributes = apply_filters('podlove_feed_enclosure_attributes', $attributes, $media_file);\n\n            foreach ($attributes as $k => $v) {\n                $element->setAttribute($k, $v ?? '');\n            }\n\n            $dom->appendChild($element);\n\n            return (string) $dom;\n        }, 10, 5);\n\n        override_feed_title($feed);\n        override_feed_description($feed);\n        override_feed_language($feed); // Is this actually doing something?\n        override_feed_head('rss2_head', $podcast, $feed, $file_type);\n        override_feed_entry('rss2_item', $podcast, $feed, $file_type);\n\n        add_action('rss2_item', function () {\n            if (apply_filters('podlove_feed_show_summary', true)) {\n                echo \\Podlove\\Feeds\\get_xml_cdata_node('description', \\Podlove\\Feeds\\get_description());\n            }\n        }, 9);\n\n        add_action('rss2_head', function () use ($podcast, $feed) {\n            global $wp_query;\n\n            $current_page = (get_query_var('paged')) ? get_query_var('paged') : 1;\n\n            $feed_url_for_page = function ($page) use ($feed) {\n                $url = $feed->get_subscribe_url();\n\n                if ($page > 0) {\n                    $url .= '?paged='.$page;\n                }\n\n                return $url;\n            };\n\n            if ($current_page < $wp_query->max_num_pages) {\n                echo \"\\n\\t\".sprintf('<atom:link rel=\"next\" href=\"%s\" />', $feed_url_for_page($current_page + 1));\n            }\n\n            if ($current_page > 2) {\n                echo \"\\n\\t\".sprintf('<atom:link rel=\"prev\" href=\"%s\" />', $feed_url_for_page($current_page - 1));\n            } elseif ($current_page == 2) {\n                echo \"\\n\\t\".sprintf('<atom:link rel=\"prev\" href=\"%s\" />', $feed_url_for_page(0));\n            }\n\n            echo \"\\n\\t\".sprintf('<atom:link rel=\"first\" href=\"%s\" />', $feed_url_for_page(0));\n\n            if ($wp_query->max_num_pages > 1) {\n                echo \"\\n\\t\".sprintf('<atom:link rel=\"last\" href=\"%s\" />', $feed_url_for_page($wp_query->max_num_pages));\n            }\n\n            if ($podcast->language) {\n                echo \"\\n\\t\".'<language>'.apply_filters('podlove_feed_language', $podcast->language).'</language>';\n            }\n\n            do_action('podlove_rss2_head', $feed);\n        }, 9);\n\n        // hint: don't move this line inside the pre_option_posts_per_rss\n        // callback because it may access the option posts_per_rss, causing an endless loop\n        $posts_per_page = $feed->get_post_limit_sql();\n\n        // now override the option so WP core functions accessing the option get the \"correct\" value\n        add_filter('pre_option_posts_per_rss', function ($_) use ($posts_per_page) {\n            return $posts_per_page;\n        });\n\n        $args = [\n            'post_type' => 'podcast',\n            'post__in' => $feed->post_ids(),\n            'posts_per_page' => $posts_per_page,\n        ];\n\n        // The theme \"getnoticed\" globally overrides post_types in pre_get_posts.\n        // Fix: hook in after the theme and override it again.\n        // It's not bad practice because I *really* only want episodes in this feed.\n        add_action('pre_get_posts', function ($query) {\n            $query->set('post_type', 'podcast');\n        }, 20);\n\n        /*\n         * In feeds, WordPress ignores the 'posts_per_page' parameter\n         * and overrides it with the 'posts_per_rss' option. So we need to\n         * override that option.\n         */\n        add_filter('post_limits', function ($limits) use ($feed) {\n            $page = get_query_var('paged') ? (int) get_query_var('paged') : 1;\n\n            $max = $feed->get_post_limit_sql();\n            $start = (int) $max * ((int) $page - 1);\n\n            if ($max > 0) {\n                return 'LIMIT '.$start.', '.$max;\n            }\n\n            return '';\n        });\n\n        $args = array_merge($wp_query->query_vars, $args);\n\n        // unset search parameter if it is empty\n        // fixes is_search() and issue with Relevanssi plugin\n        if (isset($args['s']) && empty($args['s'])) {\n            unset($args['s']);\n        }\n\n        query_posts($args);\n    }\n\n    public static function wp_404()\n    {\n        status_header(404);\n        header('Content-Type: text/html');\n        if ($template = get_404_template()) {\n            include $template;\n        }\n\n        exit;\n    }\n\n    public static function render()\n    {\n        global $wp_query;\n\n        if ($wp_query->is_comment_feed) {\n            load_template(ABSPATH.WPINC.'/feed-rss2-comments.php');\n        } else {\n            load_template(\\Podlove\\PLUGIN_DIR.'templates/feed-rss2.php');\n        }\n\n        exit;\n    }\n}\n"
  },
  {
    "path": "lib/feeds.php",
    "content": "<?php\n\nnamespace Podlove\\Feeds;\n\nuse Podlove\\Model;\n\n// Prio 11 so it hooks *after* the domain mapping plugin.\n// This is important when one moves a domain. That way the domain gets\n// remapped/redirected correctly by the domain mapper before being redirected by us.\nadd_action('template_redirect', '\\Podlove\\Feeds\\handle_feed_proxy_redirects', 11);\n\nadd_action('init', '\\Podlove\\Feeds\\register_podcast_feeds');\n\nfunction register_podcast_feeds()\n{\n    foreach (Model\\Feed::all() as $feed) {\n        if ($feed->slug) {\n            add_feed($feed->slug, '\\Podlove\\Feeds\\generate_podcast_feed');\n        }\n    }\n}\n\n/**\n * Handles feed requests.\n *\n * - ensures correct feed URL protocol\n * - ensures canonical feed URL\n * - redirects to feed proxy if necessary\n * - prepares podcast feed (adds all metadata etc. to RSS)\n */\nfunction handle_feed_proxy_redirects()\n{\n    if (!$feed = get_feed()) {\n        return;\n    }\n\n    header('Content-Type: application/rss+xml; charset='.get_option('blog_charset'), true);\n\n    maybe_redirect_to_canonical_url();\n\n    $redirect_url = $feed->get_redirect_url();\n\n    if ($redirect_url && $feed->is_redirect_enabled() && !is_page_in_feed() && should_redirect_to_proxy()) {\n        header(sprintf('Location: %s', $redirect_url), true, $feed->get_redirect_http_status_code());\n        exit;\n    }   // don't redirect; prepare feed\n    status_header(200);\n    RSS::prepare_feed($feed->slug);\n}\n\n/**\n * Is the current request part of a \"paged feed\"?\n *\n * @return bool\n */\nfunction is_page_in_feed()\n{\n    return get_query_var('paged', 1) > 1;\n}\n\n/**\n * Get canonical subscribe feed URL.\n *\n * If current request is a \"paged feed\" page, this parameter is preserved.\n *\n * @return string\n */\nfunction get_canonical_feed_url()\n{\n    if (!$feed = get_feed()) {\n        return null;\n    }\n\n    $url = $feed->get_subscribe_url();\n\n    if (is_page_in_feed()) {\n        $url = add_query_arg(['paged' => get_query_var('paged', 1)], $url);\n    }\n\n    return $url;\n}\n\n/**\n * Should the current feed request be delivered in debug mode?\n *\n * @return bool\n */\nfunction is_debug_view()\n{\n    return \\Podlove\\get_setting('website', 'feeds_skip_redirect') == 'on' && filter_input(INPUT_GET, 'redirect') == 'no';\n}\n\n/**\n * Should the current feed request be allowed to redirect to a feed proxy?\n *\n * @return bool\n */\nfunction should_redirect_to_proxy()\n{\n    // don't redirect when debug view is active\n    if (is_debug_view()) {\n        return false;\n    }\n\n    // don't redirect when a feed proxy crawler is requesting\n    if (preg_match('/feedburner|feedsqueezer|feedvalidator|feedpress/i', $_SERVER['HTTP_USER_AGENT'] ?? '')) {\n        return false;\n    }\n\n    return true;\n}\n\n/**\n * Get Feed object from current context.\n *\n * @return Podlove\\Model\\Feed\n */\nfunction get_feed()\n{\n    if (!is_feed()) {\n        return null;\n    }\n\n    if (!$feed_slug = get_query_var('feed')) {\n        return null;\n    }\n\n    return Model\\Feed::find_one_by_slug($feed_slug);\n}\n\n/**\n * Maybe redirect to canonical feed URL.\n *\n * It's important that there is only one \"correct\" subscribe URL.\n * When accessing a feed, ensure the canonical form is used and redirect if necessary.\n */\nfunction maybe_redirect_to_canonical_url()\n{\n    // do not redirect if feed debug mode is active\n    if (is_debug_view()) {\n        return;\n    }\n\n    // do not redirect if pretty permalinks are turned off\n    if (strlen(trim(get_option('permalink_structure'))) === 0) {\n        return;\n    }\n\n    if (!$feed = get_feed()) {\n        return;\n    }\n\n    $feed_url = $feed->get_subscribe_url();\n    $request_url = 'http'.(is_ssl() ? 's' : '').'://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'];\n    $url = wp_parse_url($request_url);\n\n    if (\n        !\\Podlove\\PHP\\ends_with($url['path'], '/') && \\Podlove\\PHP\\ends_with($feed_url, '/')\n        || \\Podlove\\PHP\\ends_with($url['path'], '/') && !\\Podlove\\PHP\\ends_with($feed_url, '/')\n    ) {\n        wp_redirect(get_canonical_feed_url(), 301);\n        exit;\n    }\n}\n\nfunction generate_podcast_feed()\n{\n    remove_podPress_hooks();\n    remove_powerPress_hooks();\n    RSS::render();\n}\n\nfunction check_for_and_do_compression($content_type = 'application/rss+xml')\n{\n    // ensure content type headers are set\n    if (!headers_sent()) {\n        header('Content-type: '.$content_type);\n    }\n\n    if (!apply_filters('podlove_enable_gzip_for_feeds', true)) {\n        return false;\n    }\n\n    // gzip requires zlib extension\n    if (!extension_loaded('zlib')) {\n        return false;\n    }\n\n    // if zlib output compression is already active, don't gzip\n    // (both cannot be active at the same time)\n    $ob_status = ob_get_status();\n    if (isset($ob_status['name']) && $ob_status['name'] === 'zlib output compression') {\n        return false;\n    }\n\n    // don't gzip if client doesn't accept it\n    if (stripos($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip') === false) {\n        return false;\n    }\n\n    // don't gzip if _any_ output buffering is active\n    // this can be 1 if \"output_buffering\" is not set to off / 0\n    // but better safe than sorry (otherwise there's trouble with caching plugins)\n    if (ob_get_level() > 0) {\n        return false;\n    }\n\n    // don't gzip if wprocket is active\n    if (in_array('do_rocket_callback', ob_list_handlers())) {\n        return false;\n    }\n\n    // don't gzip if gzipping is already active\n    if (in_array('ob_gzhandler', ob_list_handlers())) {\n        return false;\n    }\n\n    // don't try to use ob_gzhandler on hhvm, it's not supported\n    // (see https://github.com/facebook/hhvm/issues/1854)\n    if (defined('HHVM_VERSION')) {\n        return false;\n    }\n\n    // start gzipping\n    ob_start('ob_gzhandler');\n}\n\n/**\n * Make sure that PodPress doesn't vomit anything into our precious feeds\n * in case it is still active.\n */\nfunction remove_podPress_hooks()\n{\n    remove_filter('option_blogname', 'podPress_feedblogname');\n    remove_filter('option_blogdescription', 'podPress_feedblogdescription');\n    remove_filter('option_rss_language', 'podPress_feedblogrsslanguage');\n    remove_filter('option_rss_image', 'podPress_feedblogrssimage');\n    remove_action('rss2_ns', 'podPress_rss2_ns');\n    remove_action('rss2_head', 'podPress_rss2_head');\n    remove_filter('rss_enclosure', 'podPress_dont_print_nonpodpress_enclosures');\n    remove_action('rss2_item', 'podPress_rss2_item');\n    remove_action('atom_head', 'podPress_atom_head');\n    remove_filter('atom_enclosure', 'podPress_dont_print_nonpodpress_enclosures');\n    remove_action('atom_entry', 'podPress_atom_entry');\n}\n\nfunction remove_powerPress_hooks()\n{\n    remove_action('rss2_ns', 'powerpress_rss2_ns');\n    remove_action('rss2_head', 'powerpress_rss2_head');\n    remove_action('rss2_item', 'powerpress_rss2_item');\n}\n"
  },
  {
    "path": "lib/file_type_list_table.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass File_Type_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'file_type',   // singular name of the listed records\n            'plural' => 'file_types',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_name($file_type)\n    {\n        return $file_type->name;\n    }\n\n    public function column_id($file_type)\n    {\n        return $file_type->id;\n    }\n\n    public function column_file_type($file_type)\n    {\n        return $file_type->type;\n    }\n\n    public function column_mime($file_type)\n    {\n        return $file_type->mime_type;\n    }\n\n    public function column_extension($file_type)\n    {\n        return $file_type->extension;\n    }\n\n    public function get_columns()\n    {\n        return [\n            'id' => __('ID', 'podlove-podcasting-plugin-for-wordpress'),\n            'name' => __('Name', 'podlove-podcasting-plugin-for-wordpress'),\n            'file_type' => __('File Type', 'podlove-podcasting-plugin-for-wordpress'),\n            'mime' => __('MIME Type', 'podlove-podcasting-plugin-for-wordpress'),\n            'extension' => __('Extension', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = 1000;\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        // TODO select data for current page only\n        $data = \\Podlove\\Model\\FileType::all();\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/form/input/builder.php",
    "content": "<?php\n\nnamespace Podlove\\Form\\Input;\n\nclass Builder\n{\n    /**\n     * Model record.\n     *\n     * @var object\n     */\n    public $object;\n\n    /**\n     * Form field name prefix.\n     *\n     * @var string\n     */\n    public $context;\n\n    public $object_key;\n    public $arguments;\n\n    public $field_name;\n    public $field_id;\n    public $field_value;\n    public $html_attributes;\n\n    public function __construct($object, $context)\n    {\n        $this->object = $object;\n        $this->context = $context;\n    }\n\n    public function get_field_name()\n    {\n        return ($this->context) ? \"{$this->context}[{$this->object_key}]\" : $this->object_key;\n    }\n\n    public function get_field_id()\n    {\n        if ($this->context) {\n            $id = \"{$this->context}_{$this->object_key}\";\n        } else {\n            $id = $this->object_key;\n        }\n\n        $id = str_replace(['[', ']'], '_', $id);\n\n        return str_replace('__', '_', $id);\n    }\n\n    public function get_extra_html_attributes()\n    {\n        if (!isset($this->arguments['html']) || !is_array($this->arguments['html'])) {\n            return '';\n        }\n\n        $compiled_html = '';\n\n        foreach ($this->arguments['html'] as $key => $value) {\n            $compiled_html .= \"{$key}=\\\"{$value}\\\" \";\n        }\n\n        return $compiled_html;\n    }\n\n    public function string($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<div>\n\t\t\t<input type=\"<?php echo (isset($arguments['type']) && $arguments['type']) ? $arguments['type'] : 'text'; ?>\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" value=\"<?php echo esc_attr($this->field_value); ?>\" <?php echo $this->html_attributes; ?><?php echo !empty($arguments['type']) && 'number' == $arguments['type'] && !empty($arguments['positive_number']) ? ' onkeypress=\"return event.charCode >= 48\" min=\"0\"' : ''; ?>><span class=\"podlove-input-status\" data-podlove-input-status-for=\"<?php echo $this->field_id; ?>\"></span>\n    </div>\n\t\t<?php\n    }\n\n    public function color($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<div>\n\t\t\t<input type=\"color\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" value=\"<?php echo esc_attr($this->field_value); ?>\" <?php echo $this->html_attributes; ?>><span class=\"podlove-input-status\" data-podlove-input-status-for=\"<?php echo $this->field_id; ?>\"></span>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function hidden($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<div>\n\t\t\t<input type=\"hidden\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" value=\"<?php echo esc_attr($this->field_value); ?>\" <?php echo $this->html_attributes; ?>>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function password($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<div>\n\t\t\t<input type=\"password\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" value=\"<?php echo esc_attr($this->field_value); ?>\" <?php echo $this->html_attributes; ?>>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function text($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<div>\n\t\t\t<textarea name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" <?php echo $this->html_attributes; ?>><?php echo esc_html($this->field_value); ?></textarea><span class=\"podlove-input-status\" data-podlove-input-status-for=\"<?php echo $this->field_id; ?>\"></span>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function checkbox($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<input type=\"checkbox\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" <?php if (in_array($this->field_value, [true, 1, 'on'])) { ?>checked=\"checked\"<?php } ?> <?php echo $this->html_attributes; ?>>\n\t\t<input type=\"hidden\" name=\"checkboxes[]\" value=\"<?php echo esc_attr($this->object_key); ?>\">\n\t\t<?php\n    }\n\n    public function select($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<select name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" <?php echo $this->html_attributes; ?>>\n\t\t\t<?php if (!isset($this->arguments['please_choose']) || $this->arguments['please_choose']) { ?>\n\t\t\t\t<option value=\"\"><?php\n                    if (isset($this->arguments['please_choose_text'])) {\n                        echo $this->arguments['please_choose_text'];\n                    } else {\n                        echo __('Please choose ...', 'podlove-podcasting-plugin-for-wordpress');\n                    } ?></option>\n\t\t\t<?php } ?>\n\t\t\t<?php foreach ($this->arguments['options'] as $key => $value) { ?>\n\t\t\t\t<?php\n                if (is_array($value)) {\n                    $attributes = $value['attributes'];\n                    $value = $value['value'];\n                } else {\n                    $attributes = '';\n                } ?>\n\t\t\t\t<option value=\"<?php echo esc_attr($key); ?>\" <?php echo $attributes; ?> <?php if ($key == $this->field_value) { ?> selected=\"selected\"<?php } ?>><?php echo $value; ?></option>\n\t\t\t<?php } ?>\n\t\t</select>\n\t\t<?php\n    }\n\n    public function multiselect($object_key, $arguments)\n    {\n        $arguments['ignore_values'] = true;\n        $this->build_input_values($object_key, $arguments);\n\n        foreach ($this->arguments['options'] as $key => $value) {\n            if (isset($this->arguments['multi_values'][$key])) {\n                $checked = $this->arguments['multi_values'][$key];\n            } else {\n                $checked = $this->arguments['default'];\n            }\n\n            $name = $this->field_name.'['.$key.']';\n\n            // generate an id without braces by turning braces into underscores\n            $id = $this->field_id.'_'.$key;\n            $id = str_replace(['[', ']'], '_', $id);\n            $id = str_replace('__', '_', $id);\n\n            if (isset($this->arguments['multiselect_callback'])) {\n                $callback = call_user_func($this->arguments['multiselect_callback'], $key);\n            } else {\n                $callback = '';\n            }\n\n            $html = function () use ($id, $name, $checked, $callback, $value) {\n                ?>\n\t\t\t\t<div>\n\t\t\t\t\t<label for=\"<?php echo $id; ?>\">\n\t\t\t\t\t\t<input type=\"checkbox\" name=\"<?php echo $name; ?>\" id=\"<?php echo $id; ?>\" <?php if ($checked) { ?>checked=\"checked\"<?php } ?> <?php echo $callback; ?>> <?php echo $value; ?>\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            };\n\n            if (isset($this->arguments['around_each']) && is_callable($this->arguments['around_each'])) {\n                $this->arguments['around_each']($html);\n            } else {\n                call_user_func($html);\n            }\n        }\n    }\n\n    public function radio($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments); ?>\n\t\t<?php foreach ($this->arguments['options'] as $key => $value) { ?>\n\t\t\t<input type=\"radio\" id=\"<?php echo $this->field_id.'_'.$key; ?>\" name=\"<?php echo $this->field_name; ?>\" value=\"<?php echo esc_attr($key); ?>\"<?php if ($key == $this->field_value) { ?> checked=\"checked\"<?php } ?>>\n\t\t\t<label for=\"<?php echo $this->field_id.'_'.$key; ?>\"><?php echo $value; ?></label>\n\t\t<?php } ?>\n\t\t<?php\n    }\n\n    public function image($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments);\n\n        // determine image dimensions\n        $img_html_attributes = '';\n\n        if (isset($arguments['image_width'])) {\n            $img_html_attributes .= ' width=\"'.$arguments['image_width'].'\"';\n        }\n\n        if (isset($arguments['image_height'])) {\n            $img_html_attributes .= ' height=\"'.$arguments['image_height'].'\"';\n        } ?>\n\t\t<div>\n\t\t\t<input type=\"text\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\" value=\"<?php echo esc_attr($this->field_value); ?>\" <?php echo $this->html_attributes; ?>><span class=\"podlove-input-status\" data-podlove-input-status-for=\"<?php echo $this->field_id; ?>\"></span>\n\t\t\t<br>\n\t\t\t<img src=\"<?php echo $this->field_value; ?>\" <?php echo $img_html_attributes; ?> />\n\t\t</div>\n\t\t<script type=\"text/javascript\">\n\t\t(function($) {\n\t\t\t$(\"#<?php echo $this->field_id; ?>\").on( 'change', function() {\n\t\t\t\turl = $(this).val();\n\t\t\t\t$(this).parent().find(\"img\").attr(\"src\", url);\n\t\t\t} );\n\t\t})(jQuery);\n\t\t</script>\n\t\t<?php\n    }\n\n    public function upload($object_key, $arguments)\n    {\n        $this->build_input_values($object_key, $arguments);\n        wp_enqueue_media();\n\n        $defaults = [\n            'form_button_text' => __('Select', 'podlove-podcasting-plugin-for-wordpress'),\n            'media_button_text' => __('Use Image', 'podlove-podcasting-plugin-for-wordpress'),\n            'media_title' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n            'allow_gravatar' => false,\n            'allow_multi_upload' => false\n        ];\n        $arguments = wp_parse_args($arguments, $defaults); ?>\n\t\t<div class=\"podlove-media-upload-wrap\">\n\t\t\t<span>\n\t\t\t\t<input type=\"text\" <?php echo $this->html_attributes; ?> value=\"<?php echo esc_attr($this->field_value); ?>\" name=\"<?php echo $this->field_name; ?>\" id=\"<?php echo $this->field_id; ?>\">\n\t\t\t\t<a href=\"#\" class=\"podlove-media-upload button\"\n\t\t\t\t\tdata-target=\"<?php echo $this->field_id; ?>\"\n\t\t\t\t\tdata-title=\"<?php echo $arguments['media_title']; ?>\"\n\t\t\t\t\tdata-type=\"image\"\n\t\t\t\t\tdata-button=\"<?php echo $arguments['media_button_text']; ?>\"\n\t\t\t\t\tdata-class=\"media-frame\"\n\t\t\t\t\tdata-frame=\"select\"\n\t\t\t\t\tdata-size=\"full\"\n\t\t\t\t\tdata-state=\"podlove_select_single_image\"\n\t\t\t\t\tdata-preview=\".podlove_preview_pic\"\n\t\t\t\t\tdata-allow-gravatar=\"<?php echo $arguments['allow_gravatar']; ?>\"\n                    data-multiple=\"<?php echo $arguments['allow_multi_upload']; ?>\"\n\t\t\t\t\tdata-fetch=\"url\"><?php echo $arguments['form_button_text']; ?></a>\n\t\t\t</span>\n\t\t\t<?php if (!isset($arguments['description']) || !$arguments['description']) { ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\"><?php echo __('Enter URL or select image from media library.', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t</p>\n\t\t\t<?php } ?>\n\t\t\t<div class=\"podlove_preview_pic\"></div>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function callback($object_key, $arguments)\n    {\n        call_user_func($arguments['callback']);\n    }\n\n    /**\n     * Build nested form.\n     *\n     * @param object   $object   object that shall be modified via the form\n     * @param array    $args     list of options, all optional\n     *                           - hidden dictionary with hidden values\n     * @param function $callback inner form\n     */\n    public function fields_for($object, $args, $callback)\n    {\n        // determine context\n        $context = isset($args['context']) ? $this->context.'['.$args['context'].']'.\"[{$object->id}]\" : $this->context;\n        // build input elements\n        call_user_func($callback, new \\Podlove\\Form\\Input\\Builder($object, $context));\n    }\n\n    /**\n     * Generate values required to build input fields.\n     *\n     * @param string $object_key name of the model attribute\n     * @param array  $arguments  input field options\n     */\n    private function build_input_values($object_key, $arguments)\n    {\n        $this->object_key = $object_key;\n        $this->arguments = $arguments;\n\n        $this->field_name = $this->get_field_name();\n\n        // multiselect takes care of its values\n        if (!isset($arguments['ignore_values']) || $arguments['ignore_values'] === false) {\n            $this->field_value = $this->object->{$object_key};\n\n            if ($this->field_value === null && isset($arguments['default']) && $arguments['default']) {\n                $this->field_value = $arguments['default'];\n            }\n        }\n\n        $this->field_id = $this->get_field_id();\n        $this->html_attributes = $this->get_extra_html_attributes();\n    }\n}\n"
  },
  {
    "path": "lib/form/input/div_wrapper.php",
    "content": "<?php\n\nnamespace Podlove\\Form\\Input;\n\nclass DivWrapper extends Wrapper\n{\n    public function do_template($object_key, $field_name, $field_id, $field_values, $block)\n    {\n        ?>\n\t\t<div class=\"row_<?php echo $field_id; ?>\">\n\t\t\t<span>\n\t\t\t\t<?php if (isset($field_values['label']) && $field_values['label']) { ?>\n\t\t\t\t\t<label for=\"<?php echo $field_id; ?>\"><?php echo $field_values['label']; ?></label>\n\t\t\t\t<?php } ?>\n\t\t\t</span>\n\t\t\t<div>\n\t\t\t\t<?php call_user_func($block); ?>\n\t\t\t\t<?php if (isset($field_values['description']) && $field_values['description']) { ?>\n\t\t\t\t\t<span class=\"description\"><?php echo $field_values['description']; ?></span>\n\t\t\t\t<?php } ?>\n\t\t\t</div>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function subheader($title, $description = '')\n    {\n        ?>\n\t\t<div>\n\t\t\t<h3><?php echo $title; ?></h3>\n\t\t\t<?php if ($description) { ?>\n\t\t\t\t<em><?php echo $description; ?></em>\n\t\t\t<?php } ?>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/form/input/table_wrapper.php",
    "content": "<?php\n\nnamespace Podlove\\Form\\Input;\n\nclass TableWrapper extends Wrapper\n{\n    public function do_template($object_key, $field_name, $field_id, $field_values, $block)\n    {\n        $skiplabel = isset($field_values['nolabel']) && $field_values['nolabel']; ?>\n\t\t<tr class=\"row_<?php echo $field_id; ?>\">\n\t\t\t<?php if (!$skiplabel) { ?>\n\t\t\t\t<th scope=\"row\" valign=\"top\">\n\t\t\t\t\t<?php if (isset($field_values['label']) && $field_values['label']) { ?>\n\t\t\t\t\t\t<label for=\"<?php echo $field_id; ?>\"><?php echo $field_values['label']; ?></label>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</th>\n\t\t\t<?php } ?>\n\t\t\t<td <?php echo $skiplabel ? 'colspan=\"2\"' : ''; ?>>\n\t\t\t\t<?php call_user_func($block); ?>\n\t\t\t\t<!-- <br /> -->\n\t\t\t\t<?php if (isset($field_values['description']) && $field_values['description']) { ?>\n\t\t\t\t\t<span class=\"description\"><?php echo $field_values['description']; ?></span>\n\t\t\t\t<?php } ?>\n\t\t\t</td>\n\t\t</tr>\n\t\t<?php\n    }\n\n    public function subheader($title, $description = '')\n    {\n        ?>\n\t\t<tr>\n\t\t\t<th scope=\"row\" valign=\"top\" colspan=\"2\">\n\t\t\t\t<h3 style=\"margin-bottom: 0\"><?php echo $title; ?></h3>\n\t\t\t</th>\n\t\t</tr>\n\t\t<?php if ($description) { ?>\n\t\t\t<tr>\n\t\t\t\t<td colspan=\"2\">\n\t\t\t\t\t<?php echo $description; ?>\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t<?php } ?>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/form/input/wrapper.php",
    "content": "<?php\n\nnamespace Podlove\\Form\\Input;\n\nabstract class Wrapper\n{\n    /**\n     * Form input builder.\n     *\n     * @var \\Podlove\\Form\\Input\\Builder\n     */\n    public $builder;\n\n    public function __construct($builder)\n    {\n        $this->builder = $builder;\n    }\n\n    /**\n     * Decorate input field with html. Then forward call to input builder.\n     *\n     * @param string $name      input type name\n     * @param array  $arguments optional input arguments\n     */\n    public function __call($name, $arguments = [])\n    {\n        $builder = $this->builder;\n\n        // special case for nested forms\n        // - the first $arg is an object rather than an object key\n        // - we don't want to be wrapped in do_template()\n        if ('fields_for' === $name) {\n            call_user_func_array([$builder, $name], $arguments);\n\n            return;\n        }\n\n        $object_key = $arguments[0];\n        $this->builder->object_key = $object_key;\n\n        $field_name = $this->builder->get_field_name();\n        $field_id = $this->builder->get_field_id();\n        $field_values = (isset($arguments[1])) ? $arguments[1] : [];\n\n        if (isset($field_values['before']) && is_callable($field_values['before'])) {\n            $field_values['before']();\n        }\n\n        $this->do_template($object_key, $field_name, $field_id, $field_values, function () use ($builder, $name, $arguments) {\n            call_user_func_array([$builder, $name], $arguments);\n        });\n\n        if (isset($field_values['after']) && is_callable($field_values['after'])) {\n            $field_values['after']();\n        }\n    }\n\n    abstract public function do_template($object_key, $field_name, $field_id, $field_values, $block);\n\n    abstract public function subheader($title, $description = '');\n}\n"
  },
  {
    "path": "lib/geo_ip.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass Geo_Ip\n{\n    public const GEO_FILENAME = 'GeoLite2-City_20180206/GeoLite2-City.mmdb';\n    public const SOURCE_URL = 'http://cdn.podlove.org/publisher/GeoLite2-City_20180206.tar.gz';\n    public const TAR_NAME = '/tmp/GeoLite2-City_20180206.tar';\n\n    /**\n     * Register hooks.\n     */\n    public static function init()\n    {\n        add_filter('cron_schedules', [__CLASS__, 'cron_add_monthly']);\n        add_action('podlove_geoip_db_update', [__CLASS__, 'update_database']);\n\n        register_deactivation_hook(\\Podlove\\PLUGIN_FILE, [__CLASS__, 'stop_updater_cron']);\n        register_activation_hook(\\Podlove\\PLUGIN_FILE, [__CLASS__, 'register_updater_cron']);\n    }\n\n    public static function register_updater_cron()\n    {\n        if (!wp_next_scheduled('podlove_geoip_db_update')) {\n            wp_schedule_event(time(), 'monthly', 'podlove_geoip_db_update');\n        }\n    }\n\n    public static function stop_updater_cron()\n    {\n        wp_clear_scheduled_hook('podlove_geoip_db_update');\n    }\n\n    public static function cron_add_monthly($schedules)\n    {\n        if (!isset($schedules['monthly'])) {\n            $schedules['monthly'] = [\n                'interval' => 2635200,\n                'display' => __('Once a month', 'podlove-podcasting-plugin-for-wordpress'),\n            ];\n        }\n\n        return $schedules;\n    }\n\n    /**\n     * Is tracking enabled?\n     *\n     * @hook podlove_geo_tracking_is_enabled\n     *\n     * @return bool\n     */\n    public static function is_enabled()\n    {\n        $enabled = get_option('podlove_geo_tracking', 'on') === 'on';\n\n        return apply_filters('podlove_geo_tracking_is_enabled', $enabled);\n    }\n\n    public static function disable_tracking()\n    {\n        update_option('podlove_geo_tracking', 'off');\n    }\n\n    public static function enable_tracking()\n    {\n        update_option('podlove_geo_tracking', 'on');\n    }\n\n    public static function is_db_valid()\n    {\n        try {\n            $reader = new \\GeoIp2\\Database\\Reader(self::get_upload_file_path());\n\n            return true;\n        } catch (\\Exception $e) {\n            return false;\n        }\n    }\n\n    public static function get_upload_file_path()\n    {\n        return self::get_upload_file_dir().DIRECTORY_SEPARATOR.self::GEO_FILENAME;\n    }\n\n    public static function get_upload_file_dir()\n    {\n        $upload_dir = wp_upload_dir();\n\n        return $upload_dir['basedir'];\n    }\n\n    public static function update_database()\n    {\n        set_time_limit(0);\n\n        // for download_url()\n        require_once ABSPATH.'wp-admin/includes/file.php';\n\n        $tmpFile = \\download_url(self::SOURCE_URL);\n\n        if (is_wp_error($tmpFile)) {\n            return $tmpFile;\n        }\n\n        if (file_exists(self::TAR_NAME)) {\n            wp_delete_file(self::TAR_NAME);\n        }\n\n        try {\n            // decompress from gz\n            $p = new \\PharData($tmpFile);\n            $file = $p->decompress(); // creates files.tar\n\n            // unarchive from the tar\n            $phar = new \\PharData($file->getPath());\n            $phar->extractTo(self::get_upload_file_dir(), null, true);\n            wp_delete_file($tmpFile);\n        } catch (\\Exception $e) {\n            return new \\WP_Error('podlove_geo_ip_update_failed', $e->getMessage());\n        } catch (\\PharException $e) {\n            return new \\WP_Error('podlove_geo_ip_update_failed', $e->getMessage());\n        }\n\n        self::enable_tracking();\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "lib/has_page_documentation_trait.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Provide help tabs for admin pages.\n *\n * Usage:\n *\n * ```\n * class MySettingsPage {\n *\n * \tuse \\Podlove\\HasPageDocumentationTrait;\n *\n * \tpublic function __construct() {\n * \t\t// ...\n *\t\t$this->init_page_documentation($pagehook);\n *\t}\n *\n * }\n *\n * Then have a data file in a 'help' sudirectory:\n *\n * # ./help/my_settings_page.php\n * <?php\n * return [\n * \t'podlove_unique_tab_id' => [\n * \t\t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n * \t\t'content' => '<p>' . __('Tab Content', 'podlove-podcasting-plugin-for-wordpress') . '</p>'\n * \t]\n * ];\n * ```\n */\ntrait HasPageDocumentationTrait\n{\n    public function init_page_documentation($pagehook)\n    {\n        add_action('load-'.$pagehook, [$this, 'add_help_tabs']);\n    }\n\n    public function add_help_tabs()\n    {\n        get_current_screen()->set_help_sidebar(\n            '<p><strong>'.__('For more information:').'</strong></p>'\n            .'<p><a href=\"http://docs.podlove.org/podlove-publisher/\" target=\"_blank\">'.__('Podlove Publisher Documentation', 'podlove-podcasting-plugin-for-wordpress').'</a></p>'\n            .'<p><a href=\"https://community.podlove.org/\" target=\"_blank\">'.__('Podlove Community', 'podlove-podcasting-plugin-for-wordpress').'</a></p>'\n        );\n\n        foreach ($this->get_help_tabs() as $id => $tab) {\n            get_current_screen()->add_help_tab([\n                'id' => $id,\n                'title' => $tab['title'],\n                'callback' => function ($screen, $tab) {\n                    echo $this->get_help_tabs()[$tab['id']]['content'];\n                },\n            ]);\n        }\n    }\n\n    public static function help_file()\n    {\n        $inheriting_class_file = self::inheriting_class_file();\n\n        return\n            dirname($inheriting_class_file)\n            .DIRECTORY_SEPARATOR\n            .'help'\n            .DIRECTORY_SEPARATOR\n            .basename($inheriting_class_file);\n    }\n\n    public static function inheriting_class_file()\n    {\n        $class = new \\ReflectionClass(get_called_class());\n\n        return $class->getFileName();\n    }\n\n    private function get_help_tabs()\n    {\n        if (file_exists(self::help_file())) {\n            return include self::help_file();\n        }\n\n        return [];\n    }\n}\n"
  },
  {
    "path": "lib/helper.php",
    "content": "<?php\n\nnamespace Podlove;\n\nfunction load_template($path, $vars = [])\n{\n    $template = null;\n\n    $paths = [\n        \\Podlove\\PLUGIN_DIR.'views/'.$path.'.php',\n        \\Podlove\\PLUGIN_DIR.$path.'.php',\n    ];\n\n    foreach ($paths as $path) {\n        if (file_exists($path)) {\n            $template = $path;\n\n            break;\n        }\n    }\n\n    extract($vars);\n\n    require $template;\n}\n\nfunction maybe_encode_emoji($string)\n{\n    if (function_exists('wp_encode_emoji') && function_exists('mb_convert_encoding')) {\n        $string = \\wp_encode_emoji($string);\n    }\n\n    return $string;\n}\n\n/**\n * Duplicate of $wpdb::esc_like.\n *\n * Can be replaced once we bump WordPress version dependency to 4.0+\n *\n * @param mixed $text\n */\nfunction esc_like($text)\n{\n    return addcslashes($text, '_%\\\\');\n}\n\nfunction format_bytes($size, $decimals = 2)\n{\n    if (is_null($size)) {\n        $size = 0;\n    }\n\n    $units = [' B', ' KB', ' MB', ' GB', ' TB'];\n    for ($i = 0; $size >= 1024 && $i < 4; ++$i) {\n        $size /= 1024;\n    }\n\n    return round($size, $decimals).$units[$i];\n}\n\nfunction get_blog_prefix()\n{\n    $blog_prefix = '';\n\n    if (is_multisite() && !is_subdomain_install() && is_main_site()) {\n        $blog_prefix = '/blog';\n    }\n\n    return $blog_prefix;\n}\n\nfunction get_help_link($tab_id, $title = '<sup>?</sup>')\n{\n    return sprintf('<a href=\"#\" data-podlove-help=\"%s\">%s</a>', $tab_id, $title);\n}\n\n/**\n * Checks if given file is an image based on mime type.\n *\n * @param string $file\n * @param mixed  $filename\n *\n * @return bool\n */\nfunction is_image($file, $filename = '')\n{\n    // simple PHP based checks\n    $type = get_image_type($file);\n    $mime = get_image_mime_type($type);\n    $mime_is_image = substr($mime, 0, 5) == 'image';\n\n    // more checks using WP helpers\n    if (!$filename) {\n        $filename = basename($file);\n    }\n\n    $check = wp_check_filetype_and_ext($file, $filename);\n    $ext = isset($check['ext']) && $check['ext'] ? strtolower($check['ext']) : null;\n    $wp_type = isset($check['type']) && $check['type'] ? strtolower($check['type']) : null;\n\n    $wp_type_looks_correct = stripos($wp_type, 'image/') === 0;\n\n    // denylist some exts for extra safety\n    $danger_exts = [\n        'php', 'php3', 'php4', 'php5', 'phtml', 'phar', 'pl', 'py', 'rb', 'cgi', 'asp', 'aspx', 'jsp',\n    ];\n\n    $ext_looks_dangerous = empty($ext) || in_array($ext, $danger_exts, true);\n\n    return $mime_is_image && !$ext_looks_dangerous && $wp_type_looks_correct;\n}\n\nfunction get_image_type($file)\n{\n    if (function_exists('exif_imagetype')) {\n        return exif_imagetype($file);\n    }\n    $image = getimagesize($file);\n\n    return $image[2];\n}\n\nfunction get_image_mime_type($image_type)\n{\n    return image_type_to_mime_type($image_type);\n}\n\nfunction get_setting_defaults()\n{\n    return [\n        'website' => [\n            'merge_episodes' => 'on',\n            'hide_wp_feed_discovery' => 'off',\n            'use_post_permastruct' => 'on',\n            'custom_episode_slug' => '/podcast/%podcast%/',\n            'episode_archive' => 'on',\n            'episode_archive_slug' => '/podcast/',\n            'url_template' => '%media_file_base_url%%episode_slug%%suffix%.%format_extension%',\n            'ssl_verify_peer' => 'on',\n            'landing_page' => 'homepage',\n            'feeds_skip_redirect' => 'off',\n            'enable_generated_blog_post_title' => false,\n            'blog_title_template' => '%mnemonic%%episode_number% %episode_title%',\n            'episode_number_padding' => 3,\n        ],\n        'metadata' => [\n            'enable_episode_recording_date' => 0,\n            'enable_episode_explicit' => 0,\n            'enable_episode_license' => 0,\n        ],\n        'redirects' => [\n            'podlove_setting_redirect' => [],\n        ],\n        'tracking' => [\n            'mode' => 'ptm_analytics',\n            'window' => 'hourly',\n        ],\n        'notifications' => [\n            'delay' => 1,\n            'subject' => '\"{{ podcast.title }}\" Episode Published: {{ episode.title }}',\n            'body' => 'Hi {{ contributor.name }},\n\nYou get this email because you were part of \"{{ podcast.title }}\".\nEpisode \"{{ episode.title }}\" was just released.\n\nURL: {{ episode.url }}\n\n{% if podcast.ownerName %}\nRegards,\n{{ podcast.ownerName }}\n{% endif %}',\n            'send_as' => null,\n            'group' => null,\n            'role' => null,\n        ],\n        'notifications_test' => [\n            'receiver' => '',\n            'episode' => 0,\n        ],\n    ];\n}\n\nfunction get_setting($namespace, $name)\n{\n    $defaults = get_setting_defaults();\n\n    $options = get_option('podlove_'.$namespace);\n    $options = wp_parse_args($options, $defaults[$namespace]);\n\n    if (isset($options[$name])) {\n        return $options[$name];\n    }\n\n    return null;\n}\n\nfunction save_setting($namespace, $name, $values)\n{\n    update_option('podlove_'.$namespace, [$name => $values]);\n}\n\n/**\n * Are we on the WordPress Settings API save page?\n *\n * DO NOT USE filter_input here. There seems to be a PHP bug that on some\n * systems prevents filter_input to work for INPUT_SERVER and INPUT_ENV.\n *\n * @see  http://stackoverflow.com/questions/25232975/php-filter-inputinput-server-request-method-returns-null\n *\n * @return bool\n */\nfunction is_options_save_page()\n{\n    $self = $_SERVER['PHP_SELF'];\n    $request = $_SERVER['REQUEST_URI'];\n\n    return stripos($self, 'options.php') !== false || stripos($request, 'options.php') !== false;\n}\n\n/**\n * Are we on a Podlove Settings screen?\n *\n * @return bool\n */\nfunction is_podlove_settings_screen()\n{\n    $screen = get_current_screen();\n\n    return stripos($screen->id, 'podlove') !== false && $screen->id != 'settings_page_podlove-web-player-settings';\n}\n\n/**\n * Are we on an edit screen for episodes?\n *\n * @return bool\n */\nfunction is_episode_edit_screen()\n{\n    $screen = get_current_screen();\n\n    return in_array($screen->base, ['edit', 'post']) && $screen->post_type == 'podcast';\n}\n\n/**\n * Podcast Landing Page URL.\n *\n * @todo  move to Model\\Podcast->get_landing_page_url()\n *\n * @return string\n */\nfunction get_landing_page_url()\n{\n    $landing_page = \\Podlove\\get_setting('website', 'landing_page');\n\n    switch ($landing_page) {\n        case 'homepage':\n            return home_url();\n\n            break;\n        case 'archive':\n            if ('on' == \\Podlove\\get_setting('website', 'episode_archive')) {\n                $archive_slug = trim(\\Podlove\\get_setting('website', 'episode_archive_slug'), '/');\n\n                $blog_prefix = \\Podlove\\get_blog_prefix();\n                $blog_prefix = $blog_prefix ? trim($blog_prefix, '/').'/' : '';\n\n                return trailingslashit(get_option('home').$blog_prefix).$archive_slug;\n            }\n\n            break;\n\n        default:\n            if (is_numeric($landing_page)) {\n                if ($link = get_permalink($landing_page)) {\n                    return $link;\n                }\n            }\n\n            break;\n    }\n\n    // always default to home page\n    return home_url();\n}\n\nfunction get_webplayer_defaults()\n{\n    return [\n        'version' => 'player_v5',\n        'playerv3theme' => 'pwp-dark-green.min.css',\n        'podigeetheme' => 'default',\n        'playerv4_color_primary' => get_background_color(),\n        'playerv4_color_secondary' => get_header_textcolor(),\n        'playerv4_visible_components' => [\n            'controlChapters' => 'on',\n            'controlSteppers' => 'on',\n            'episodeTitle' => 'on',\n            'poster' => 'on',\n            'progressbar' => 'on',\n            'showTitle' => 'on',\n            'subtitle' => 'on',\n            'tabAudio' => 'on',\n            'tabChapters' => 'on',\n            'tabFiles' => 'on',\n            'tabShare' => 'on',\n            'tabInfo' => 'on',\n            'tabTranscripts' => 'on',\n        ],\n        'playerv4_use_podcast_language' => false,\n    ];\n}\n\nfunction get_webplayer_settings()\n{\n    $settings = get_option('podlove_webplayer_settings', []);\n    $settings = array_filter($settings);\n\n    return wp_parse_args($settings, get_webplayer_defaults());\n}\n\nfunction get_webplayer_setting($name)\n{\n    return get_webplayer_settings()[$name];\n}\n\n// create slugs for text/titles\nfunction slugify($slug)\n{\n    $slug = trim($slug);\n    // replace everything but unreserved characters (RFC 3986 section 2.3) and slashes by a hyphen\n    $slug = preg_replace('~[^\\pL\\d_\\.\\~/]~u', '-', $slug);\n    $slug = rawurlencode($slug);\n    $slug = str_replace('%2F', '/', $slug);\n\n    return empty($slug) ? 'n-a' : $slug;\n}\n\n// prepare an existing episode slug for use in URL\nfunction prepare_episode_slug_for_url($slug)\n{\n    $slug = trim($slug);\n    $slug = rawurlencode($slug);\n\n    // allow directories in slug\n    return str_replace('%2F', '/', $slug);\n}\n\nfunction with_blog_scope($blog_id, $callback)\n{\n    $result = null;\n\n    if ($blog_id != get_current_blog_id()) {\n        switch_to_blog($blog_id);\n        $result = $callback();\n        restore_current_blog();\n    } else {\n        $result = $callback();\n    }\n\n    return $result;\n}\n\nfunction relative_time_steps($time)\n{\n    $time_diff = time() - $time;\n    $formated_time_string = date('Y-m-d h:i:s', $time);\n\n    if ($time_diff == 0) {\n        return __('Now', 'podlove-podcasting-plugin-for-wordpress');\n    }\n    $time_text = $formated_time_string;\n\n    if ($time_diff < 60) {\n        $time_text = __('Just now', 'podlove-podcasting-plugin-for-wordpress');\n    } elseif ($time_diff < 120) {\n        $time_text = __('1 minute ago', 'podlove-podcasting-plugin-for-wordpress');\n    } elseif ($time_diff < 3600) {\n        $time_text = sprintf(__('%s minutes ago', 'podlove-podcasting-plugin-for-wordpress'), floor($time_diff / 60));\n    } elseif ($time_diff < 7200) {\n        $time_text = __('1 hour ago', 'podlove-podcasting-plugin-for-wordpress');\n    } elseif ($time_diff < 86400) {\n        $time_text = sprintf(__('%s hours ago', 'podlove-podcasting-plugin-for-wordpress'), floor($time_diff / 3600));\n    }\n\n    return sprintf('<span title=\"%s\">%s</span>', $formated_time_string, $time_text);\n}\n\nfunction episode_types()\n{\n    return [\n        'full' => __('full (complete content of an episode)', 'podlove-podcasting-plugin-for-wordpress'),\n        'trailer' => __('trailer (short, promotional piece of content that represents a preview of an episode)', 'podlove-podcasting-plugin-for-wordpress'),\n        'bonus' => __('bonus (extra content for an episode, for example behind the scenes information)', 'podlove-podcasting-plugin-for-wordpress'),\n    ];\n}\n\nfunction download_external_image_to_media($url, $name, $curl_args = [])\n{\n    if (!$url) {\n        return;\n    }\n\n    if (!function_exists('\\download_url')) {\n        require_once ABSPATH.'wp-admin/includes/file.php';\n    }\n\n    if (!function_exists('\\media_handle_sideload')) {\n        require_once ABSPATH.'wp-admin/includes/media.php';\n    }\n\n    if (!function_exists('\\wp_read_image_metadata')) {\n        require_once ABSPATH.'wp-admin/includes/image.php';\n    }\n\n    $r = \\Podlove\\Model\\Image::download_url($url, 300, $curl_args);\n\n    if (\\is_wp_error($r)) {\n        return $r;\n    }\n\n    list($tmp, $resp) = $r;\n\n    $file_array = [\n        'name' => $name,\n        'tmp_name' => $tmp\n    ];\n\n    // unlink file if there were download errors\n    if (\\is_wp_error($tmp)) {\n        wp_delete_file($file_array['tmp_name']);\n\n        return $tmp;\n    }\n\n    // set post_id to 0 so it is not attached to any post\n    $post_id = '0';\n\n    $id = \\media_handle_sideload($file_array, $post_id);\n\n    if (\\is_wp_error($id)) {\n        wp_delete_file($file_array['tmp_name']);\n\n        return $id;\n    }\n\n    return $id;\n}\n\nnamespace Podlove\\Form;\n\n/**\n * Build whole form.\n *\n * @param object   $object   object that shall be modified via the form\n * @param array    $args     list of options, all optional\n *                           - action        form action url\n *                           - method        get, post\n *                           - hidden        dictionary with hidden values\n *                           - submit_button set to false to hide the submit button\n *                           - form          set to false to skip <form> wrapper\n *                           - attributes    optional html attributes for form tag\n *                           - is_table      is it a table form? defaults to true\n * @param function $callback inner form\n *\n * @todo  refactor into a wrapper so the <table> is optional\n * @todo  hidden fields should be added via input builders\n */\nfunction build_for($object, $args, $callback)\n{\n    // determine form action url\n    if (isset($args['action'])) {\n        $url = $args['action'];\n    } else {\n        $url = is_admin() ? 'admin.php' : '';\n        $page = htmlspecialchars($_GET['page'] ?? '');\n        if ($page) {\n            $url .= '?page='.$page;\n        }\n    }\n\n    // determine form html attributes\n    $attributes_html = '';\n    if (isset($args['attributes'])) {\n        $attributes = [];\n        foreach ($args['attributes'] as $attr_key => $attr_value) {\n            $attributes[] = sprintf('%s = \"%s\"', $attr_key, esc_attr($attr_value));\n        }\n        $attributes_html = implode(' ', $attributes);\n    }\n\n    // determine method\n    $method = isset($args['method']) ? $args['method'] : 'post';\n\n    // determine context\n    $context = isset($args['context']) ? $args['context'] : '';\n\n    // check if <form> should be printed\n    $print_form = !isset($args['form']) || $args['form'] === true; ?>\n\t<?php if ($print_form) { ?>\n\t\t<form action=\"<?php echo esc_url($url); ?>\" method=\"<?php echo esc_attr($method); ?>\" <?php echo $attributes_html; ?>>\n\t<?php } ?>\n\n    <?php\n    $nonce_action = isset($args['nonce']) ? $args['nonce'] : 'podlove_generic_form_action';\n    $nonce_name = '_podlove_nonce';\n    wp_nonce_field($nonce_action, $nonce_name);\n    // verify in form processor:\n    // if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_generic_form_action')) {\n    //     return;\n    // }\n    ?>\n\n\t<?php if (isset($args['hidden']) && $args['hidden']) { ?>\n\t\t<?php foreach ($args['hidden'] as $name => $value) { ?>\n\t\t\t<input type=\"hidden\" name=\"<?php echo esc_attr($name); ?>\" value=\"<?php echo esc_attr($value); ?>\" />\n\t\t<?php } ?>\n\t<?php } ?>\n\n\t<?php if (!isset($args['is_table']) || $args['is_table'] !== false) { ?>\n\t\t<table class=\"form-table\">\n\t<?php } ?>\n\t<?php call_user_func($callback, new \\Podlove\\Form\\Input\\Builder($object, $context)); ?>\n\t<?php if (!isset($args['is_table']) || $args['is_table'] !== false) { ?>\n\t\t</table>\n\t<?php } ?>\n\n\t<?php if (!isset($args['submit_button']) || $args['submit_button'] === true) { ?>\n\t\t<?php submit_button(); ?>\n\t<?php } ?>\n\n\t<?php if (isset($args['form_end']) && is_callable($args['form_end'])) { ?>\n\t\t<?php call_user_func($args['form_end']); ?>\n\t<?php } ?>\n\n\t<?php if ($print_form) { ?>\n\t\t</form>\n\t<?php } ?>\n\n\t<?php\n}\n\nnamespace Podlove\\License;\n\nfunction version_per_country_cc()\n{\n    $version_per_country_cc = [\n        'international' => ['version' => '3.0', 'name' => 'Unported'],\n        'ar' => ['version' => '2.5'],\n        'au' => ['version' => '3.0'],\n        'at' => ['version' => '3.0'],\n        'be' => ['version' => '2.0'],\n        'br' => ['version' => '3.0'],\n        'bg' => ['version' => '2.5'],\n        'ca' => ['version' => '2.5'],\n        'cl' => ['version' => '3.0'],\n        'cn' => ['version' => '3.0'],\n        'co' => ['version' => '2.5'],\n        'cr' => ['version' => '3.0'],\n        'hr' => ['version' => '3.0'],\n        'cz' => ['version' => '3.0'],\n        'dk' => ['version' => '2.5'],\n        'ec' => ['version' => '3.0'],\n        'eg' => ['version' => '3.0'],\n        'ee' => ['version' => '3.0'],\n        'fi' => ['version' => '1.0'],\n        'fr' => ['version' => '3.0'],\n        'de' => ['version' => '3.0'],\n        'gr' => ['version' => '3.0'],\n        'gt' => ['version' => '3.0'],\n        'hk' => ['version' => '3.0'],\n        'hu' => ['version' => '2.5'],\n        'igo' => ['version' => '3.0'],\n        'in' => ['version' => '2.5'],\n        'ie' => ['version' => '3.0'],\n        'il' => ['version' => '2.5'],\n        'it' => ['version' => '3.0'],\n        'jp' => ['version' => '2.1'],\n        'lu' => ['version' => '3.0'],\n        'mk' => ['version' => '2.5'],\n        'my' => ['version' => '2.5'],\n        'mt' => ['version' => '2.5'],\n        'mx' => ['version' => '2.5'],\n        'nl' => ['version' => '3.0'],\n        'nz' => ['version' => '3.0'],\n        'no' => ['version' => '3.0'],\n        'pe' => ['version' => '2.5'],\n        'ph' => ['version' => '3.0'],\n        'pl' => ['version' => '3.0'],\n        'pt' => ['version' => '3.0'],\n        'pr' => ['version' => '3.0'],\n        'ro' => ['version' => '3.0'],\n        'rs' => ['version' => '3.0'],\n        'sg' => ['version' => '3.0'],\n        'si' => ['version' => '2.5'],\n        'za' => ['version' => '2.5'],\n        'kp' => ['version' => '2.0'],\n        'es' => ['version' => '3.0'],\n        'se' => ['version' => '2.5'],\n        'ch' => ['version' => '3.0'],\n        'tw' => ['version' => '3.0'],\n        'th' => ['version' => '3.0'],\n        'gb' => ['version' => '2.0'],\n        'gb_sc' => ['version' => '2.5'],\n        'ug' => ['version' => '3.0'],\n        'us' => ['version' => '3.0'],\n        'vn' => ['version' => '3.0'],\n    ];\n    asort($version_per_country_cc);\n\n    return $version_per_country_cc;\n}\n\nfunction locales_cc()\n{\n    $locales = [\n        'international' => 'International',\n        'ar' => 'Argentina',\n        'au' => 'Australia',\n        'at' => 'Austria',\n        'be' => 'Belgium',\n        'br' => 'Brazil',\n        'bg' => 'Bulgaria',\n        'ca' => 'Canada',\n        'cl' => 'Chile',\n        'cn' => 'China Mainland',\n        'co' => 'Colombia',\n        'cr' => 'Costa Rica',\n        'hr' => 'Croatia',\n        'cz' => 'Czech Republic',\n        'dk' => 'Denmark',\n        'ec' => 'Ecuador',\n        'eg' => 'Egypt',\n        'ee' => 'Estonia',\n        'fi' => 'Finland',\n        'fr' => 'France',\n        'de' => 'Germany',\n        'gr' => 'Greece',\n        'gt' => 'Guatemala',\n        'hk' => 'Hong Kong',\n        'hu' => 'Hungary',\n        'igo' => 'IGO',\n        'in' => 'India',\n        'ie' => 'Ireland',\n        'il' => 'Israel',\n        'it' => 'Italy',\n        'jp' => 'Japan',\n        'lu' => 'Luxembourg',\n        'mk' => 'Macedonia',\n        'my' => 'Malaysia',\n        'mt' => 'Malta',\n        'mx' => 'Mexico',\n        'nl' => 'Netherlands',\n        'nz' => 'New Zealand',\n        'no' => 'Norway',\n        'pe' => 'Peru',\n        'ph' => 'Philippines',\n        'pl' => 'Poland',\n        'pt' => 'Portugal',\n        'pr' => 'Puerto Rico',\n        'ro' => 'Romania',\n        'rs' => 'Serbia',\n        'sg' => 'Singapore',\n        'si' => 'Slovenia',\n        'za' => 'South Africa',\n        'kp' => 'South Korea',\n        'es' => 'Spain',\n        'se' => 'Sweden',\n        'ch' => 'Switzerland',\n        'tw' => 'Taiwan',\n        'th' => 'Thailand',\n        'gb' => 'UK: England & Wales',\n        'gb_sc' => 'UK: Scotland',\n        'ug' => 'Uganda',\n        'us' => 'United States',\n        'vn' => 'Vietnam',\n    ];\n    asort($locales);\n\n    return $locales;\n}\n\nnamespace Podlove\\Locale;\n\nfunction locales()\n{\n    $locales = [\n        'af' => 'Afrikaans',\n        'af-ZA' => 'Afrikaans - South Africa',\n        'ar' => 'Arabic',\n        'ar-AE' => 'Arabic - United Arab Emirates',\n        'ar-BH' => 'Arabic - Bahrain',\n        'ar-DZ' => 'Arabic - Algeria',\n        'ar-EG' => 'Arabic - Egypt',\n        'ar-IQ' => 'Arabic - Iraq',\n        'ar-JO' => 'Arabic - Jordan',\n        'ar-KW' => 'Arabic - Kuwait',\n        'ar-LB' => 'Arabic - Lebanon',\n        'ar-LY' => 'Arabic - Libya',\n        'ar-MA' => 'Arabic - Morocco',\n        'ar-OM' => 'Arabic - Oman',\n        'ar-QA' => 'Arabic - Qatar',\n        'ar-SA' => 'Arabic - Saudi Arabia',\n        'ar-SY' => 'Arabic - Syria',\n        'ar-TN' => 'Arabic - Tunisia',\n        'ar-YE' => 'Arabic - Yemen',\n        'az' => 'Azeri',\n        'az-AZ-Cyrl' => 'Azeri (Cyrillic) - Azerbaijan',\n        'az-AZ-Latn' => 'Azeri (Latin) - Azerbaijan',\n        'be' => 'Belarusian',\n        'be-BY' => 'Belarusian - Belarus',\n        'bg' => 'Bulgarian',\n        'bg-BG' => 'Bulgarian - Bulgaria',\n        'ca' => 'Catalan',\n        'ca-ES' => 'Catalan - Catalan',\n        'cs' => 'Czech',\n        'cs-CZ' => 'Czech - Czech Republic',\n        'da' => 'Danish',\n        'da-DK' => 'Danish - Denmark',\n        'de' => 'German',\n        'de-AT' => 'German - Austria',\n        'de-CH' => 'German - Switzerland',\n        'de-DE' => 'German - Germany',\n        'de-LI' => 'German - Liechtenstein',\n        'de-LU' => 'German - Luxembourg',\n        'div' => 'Dhivehi',\n        'div-MV' => 'Dhivehi - Maldives',\n        'el' => 'Greek',\n        'el-GR' => 'Greek - Greece',\n        'en' => 'English',\n        'en-AU' => 'English - Australia',\n        'en-BZ' => 'English - Belize',\n        'en-CA' => 'English - Canada',\n        'en-CB' => 'English - Caribbean',\n        'en-GB' => 'English - United Kingdom',\n        'en-IE' => 'English - Ireland',\n        'en-JM' => 'English - Jamaica',\n        'en-NZ' => 'English - New Zealand',\n        'en-PH' => 'English - Philippines',\n        'en-TT' => 'English - Trinidad and Tobago',\n        'en-US' => 'English - United States',\n        'en-ZA' => 'English - South Africa',\n        'en-ZW' => 'English - Zimbabwe',\n        'eo' => 'Esperanto',\n        'es' => 'Spanish',\n        'es-AR' => 'Spanish - Argentina',\n        'es-BO' => 'Spanish - Bolivia',\n        'es-CL' => 'Spanish - Chile',\n        'es-CO' => 'Spanish - Colombia',\n        'es-CR' => 'Spanish - Costa Rica',\n        'es-DO' => 'Spanish - Dominican Republic',\n        'es-EC' => 'Spanish - Ecuador',\n        'es-ES' => 'Spanish - Spain',\n        'es-GT' => 'Spanish - Guatemala',\n        'es-HN' => 'Spanish - Honduras',\n        'es-MX' => 'Spanish - Mexico',\n        'es-NI' => 'Spanish - Nicaragua',\n        'es-PA' => 'Spanish - Panama',\n        'es-PE' => 'Spanish - Peru',\n        'es-PR' => 'Spanish - Puerto Rico',\n        'es-PY' => 'Spanish - Paraguay',\n        'es-SV' => 'Spanish - El Salvador',\n        'es-UY' => 'Spanish - Uruguay',\n        'es-VE' => 'Spanish - Venezuela',\n        'et' => 'Estonian',\n        'et-EE' => 'Estonian - Estonia',\n        'eu' => 'Basque',\n        'eu-ES' => 'Basque - Basque',\n        'fa' => 'Farsi',\n        'fa-IR' => 'Farsi - Iran',\n        'fi' => 'Finnish',\n        'fi-FI' => 'Finnish - Finland',\n        'fo' => 'Faroese',\n        'fo-FO' => 'Faroese - Faroe Islands',\n        'fr' => 'French',\n        'fr-BE' => 'French - Belgium',\n        'fr-CA' => 'French - Canada',\n        'fr-CH' => 'French - Switzerland',\n        'fr-FR' => 'French - France',\n        'fr-LU' => 'French - Luxembourg',\n        'fr-MC' => 'French - Monaco',\n        'gl' => 'Galician',\n        'gl-ES' => 'Galician - Galician',\n        'gu' => 'Gujarati',\n        'gu-IN' => 'Gujarati - India',\n        'he' => 'Hebrew',\n        'he-IL' => 'Hebrew - Israel',\n        'hi' => 'Hindi',\n        'hi-IN' => 'Hindi - India',\n        'hr' => 'Croatian',\n        'hr-HR' => 'Croatian - Croatia',\n        'hu' => 'Hungarian',\n        'hu-HU' => 'Hungarian - Hungary',\n        'hy' => 'Armenian',\n        'hy-AM' => 'Armenian - Armenia',\n        'id' => 'Indonesian',\n        'id-ID' => 'Indonesian - Indonesia',\n        'is' => 'Icelandic',\n        'is-IS' => 'Icelandic - Iceland',\n        'it' => 'Italian',\n        'it-CH' => 'Italian - Switzerland',\n        'it-IT' => 'Italian - Italy',\n        'ja' => 'Japanese',\n        'ja-JP' => 'Japanese - Japan',\n        'ka' => 'Georgian',\n        'ka-GE' => 'Georgian - Georgia',\n        'kk' => 'Kazakh',\n        'kk-KZ' => 'Kazakh - Kazakhstan',\n        'kn' => 'Kannada',\n        'kn-IN' => 'Kannada - India',\n        'ko' => 'Korean',\n        'ko-KR' => 'Korean - Korea',\n        'kok' => 'Konkani',\n        'kok-IN' => 'Konkani - India',\n        'ky' => 'Kyrgyz',\n        'ky-KG' => 'Kyrgyz - Kyrgyzstan',\n        'lb' => 'Luxembourgish',\n        'lt' => 'Lithuanian',\n        'lt-LT' => 'Lithuanian - Lithuania',\n        'lv' => 'Latvian',\n        'lv-LV' => 'Latvian - Latvia',\n        'mk' => 'Macedonian',\n        'mk-MK' => 'Macedonian - Former Yugoslav Republic of Macedonia',\n        'mn' => 'Mongolian',\n        'mn-MN' => 'Mongolian - Mongolia',\n        'mr' => 'Marathi',\n        'mr-IN' => 'Marathi - India',\n        'ms' => 'Malay',\n        'ms-BN' => 'Malay - Brunei',\n        'ms-MY' => 'Malay - Malaysia',\n        'nb-NO' => 'Norwegian (Bokm�l) - Norway',\n        'nl' => 'Dutch',\n        'nl-BE' => 'Dutch - Belgium',\n        'nl-NL' => 'Dutch - The Netherlands',\n        'nn-NO' => 'Norwegian (Nynorsk) - Norway',\n        'no' => 'Norwegian',\n        'pa' => 'Punjabi',\n        'pa-IN' => 'Punjabi - India',\n        'pl' => 'Polish',\n        'pl-PL' => 'Polish - Poland',\n        'pt' => 'Portuguese',\n        'pt-BR' => 'Portuguese - Brazil',\n        'pt-PT' => 'Portuguese - Portugal',\n        'ro' => 'Romanian',\n        'ro-RO' => 'Romanian - Romania',\n        'ru' => 'Russian',\n        'ru-RU' => 'Russian - Russia',\n        'sa' => 'Sanskrit',\n        'sa-IN' => 'Sanskrit - India',\n        'sk' => 'Slovak',\n        'sk-SK' => 'Slovak - Slovakia',\n        'sl' => 'Slovenian',\n        'sl-SI' => 'Slovenian - Slovenia',\n        'sq' => 'Albanian',\n        'sq-AL' => 'Albanian - Albania',\n        'sr-SP-Cyrl' => 'Serbian (Cyrillic) - Serbia',\n        'sr-SP-Latn' => 'Serbian (Latin) - Serbia',\n        'sv' => 'Swedish',\n        'sv-FI' => 'Swedish - Finland',\n        'sv-SE' => 'Swedish - Sweden',\n        'sw' => 'Swahili',\n        'sw-KE' => 'Swahili - Kenya',\n        'syr' => 'Syriac',\n        'syr-SY' => 'Syriac - Syria',\n        'ta' => 'Tamil',\n        'ta-IN' => 'Tamil - India',\n        'te' => 'Telugu',\n        'te-IN' => 'Telugu - India',\n        'th' => 'Thai',\n        'th-TH' => 'Thai - Thailand',\n        'tr' => 'Turkish',\n        'tr-TR' => 'Turkish - Turkey',\n        'tt' => 'Tatar',\n        'tt-RU' => 'Tatar - Russia',\n        'uk' => 'Ukrainian',\n        'uk-UA' => 'Ukrainian - Ukraine',\n        'ur' => 'Urdu',\n        'ur-PK' => 'Urdu - Pakistan',\n        'uz' => 'Uzbek',\n        'uz-UZ-Cyrl' => 'Uzbek (Cyrillic) - Uzbekistan',\n        'uz-UZ-Latn' => 'Uzbek (Latin) - Uzbekistan',\n        'vi' => 'Vietnamese',\n        'zh-CHS' => 'Chinese (Simplified)',\n        'zh-CHT' => 'Chinese (Traditional)',\n        'zh-CN' => 'Chinese - China',\n        'zh-HK' => 'Chinese - Hong Kong SAR',\n        'zh-MO' => 'Chinese - Macao SAR',\n        'zh-SG' => 'Chinese - Singapore',\n        'zh-TW' => 'Chinese - Taiwan',\n    ];\n    asort($locales);\n\n    return $locales;\n}\n\nnamespace Podlove\\Itunes;\n\n/**\n * iTunes category generator.\n *\n * Gratefully borrowed from powerpress.\n *\n * @param bool $prefix_subcategories\n *\n * @return array\n */\nfunction categories($prefix_subcategories = true)\n{\n    $temp = [];\n    $temp['01-00'] = 'Arts';\n    $temp['01-01'] = 'Design';\n    $temp['01-02'] = 'Fashion & Beauty';\n    $temp['01-03'] = 'Food';\n    $temp['01-04'] = 'Books';\n    $temp['01-05'] = 'Performing Arts';\n    $temp['01-06'] = 'Visual Arts';\n\n    $temp['02-00'] = 'Business';\n    $temp['02-02'] = 'Careers';\n    $temp['02-03'] = 'Investing';\n    $temp['02-04'] = 'Management';\n    $temp['02-06'] = 'Entrepreneurship';\n    $temp['02-07'] = 'Marketing';\n    $temp['02-08'] = 'Non-Profit';\n\n    $temp['03-00'] = 'Comedy';\n    $temp['03-01'] = 'Comedy Interviews';\n    $temp['03-02'] = 'Improv';\n    $temp['03-03'] = 'Stand-Up';\n\n    $temp['04-00'] = 'Education';\n    $temp['04-04'] = 'Language Learning';\n    $temp['04-05'] = 'Courses';\n    $temp['04-06'] = 'How To';\n    $temp['04-07'] = 'Self-Improvement';\n\n    $temp['20-00'] = 'Fiction';\n    $temp['20-01'] = 'Comedy Fiction';\n    $temp['20-02'] = 'Drama';\n    $temp['20-03'] = 'Science Fiction';\n\n    $temp['06-00'] = 'Government';\n\n    $temp['30-00'] = 'History';\n\n    $temp['07-00'] = 'Health & Fitness';\n    $temp['07-01'] = 'Alternative Health';\n    $temp['07-02'] = 'Fitness';\n    // $temp['07-03'] = 'Self-Help';\n    $temp['07-04'] = 'Sexuality';\n    $temp['07-05'] = 'Medicine';\n    $temp['07-06'] = 'Mental Health';\n    $temp['07-07'] = 'Nutrition';\n\n    $temp['08-00'] = 'Kids & Family';\n    $temp['08-01'] = 'Education for Kids';\n    $temp['08-02'] = 'Parenting';\n    $temp['08-03'] = 'Pets & Animals';\n    $temp['08-04'] = 'Stories for Kids';\n\n    $temp['40-00'] = 'Leisure';\n    $temp['40-01'] = 'Animation & Manga';\n    $temp['40-02'] = 'Automotive';\n    $temp['40-03'] = 'Aviation';\n    $temp['40-04'] = 'Crafts';\n    $temp['40-05'] = 'Games';\n    $temp['40-06'] = 'Hobbies';\n    $temp['40-07'] = 'Home & Garden';\n    $temp['40-08'] = 'Video Games';\n\n    $temp['09-00'] = 'Music';\n    $temp['09-01'] = 'Music Commentary';\n    $temp['09-02'] = 'Music History';\n    $temp['09-03'] = 'Music Interviews';\n\n    $temp['10-00'] = 'News';\n    $temp['10-01'] = 'Business News';\n    $temp['10-02'] = 'Daily News';\n    $temp['10-03'] = 'Entertainment News';\n    $temp['10-04'] = 'News Commentary';\n    $temp['10-05'] = 'Politics';\n    $temp['10-06'] = 'Sports News';\n    $temp['10-07'] = 'Tech News';\n\n    $temp['11-00'] = 'Religion & Spirituality';\n    $temp['11-01'] = 'Buddhism';\n    $temp['11-02'] = 'Christianity';\n    $temp['11-03'] = 'Hinduism';\n    $temp['11-04'] = 'Islam';\n    $temp['11-05'] = 'Judaism';\n    $temp['11-06'] = 'Religion';\n    $temp['11-07'] = 'Spirituality';\n\n    $temp['12-00'] = 'Science';\n    $temp['12-01'] = 'Medicine';\n    $temp['12-02'] = 'Natural Sciences';\n    $temp['12-03'] = 'Social Sciences';\n    $temp['12-04'] = 'Astronomy';\n    $temp['12-05'] = 'Chemistry';\n    $temp['12-06'] = 'Earth Sciences';\n    $temp['12-07'] = 'Life Sciences';\n    $temp['12-08'] = 'Mathematics';\n    $temp['12-09'] = 'Nature';\n    $temp['12-10'] = 'Physics';\n\n    $temp['13-00'] = 'Society & Culture';\n    // $temp['13-01'] = 'History';\n    $temp['13-02'] = 'Personal Journals';\n    $temp['13-03'] = 'Philosophy';\n    $temp['13-04'] = 'Places & Travel';\n    $temp['13-05'] = 'Relationships';\n    $temp['13-06'] = 'Documentary';\n\n    $temp['14-00'] = 'Sports';\n    $temp['14-05'] = 'Baseball';\n    $temp['14-06'] = 'Basketball';\n    $temp['14-07'] = 'Cricket';\n    $temp['14-08'] = 'Fantasy Sports';\n    $temp['14-09'] = 'Football';\n    $temp['14-10'] = 'Golf';\n    $temp['14-11'] = 'Hockey';\n    $temp['14-12'] = 'Rugby';\n    $temp['14-13'] = 'Running';\n    $temp['14-14'] = 'Soccer';\n    $temp['14-15'] = 'Swimming';\n    $temp['14-16'] = 'Tennis';\n    $temp['14-17'] = 'Volleyball';\n    $temp['14-18'] = 'Wilderness';\n    $temp['14-19'] = 'Wrestling';\n\n    $temp['15-00'] = 'Technology';\n\n    $temp['50-00'] = 'True Crime';\n\n    $temp['16-00'] = 'TV & Film';\n    $temp['16-01'] = 'After Shows';\n    $temp['16-02'] = 'Film History';\n    $temp['16-03'] = 'Film Interviews';\n    $temp['16-04'] = 'Film Reviews';\n    $temp['16-05'] = 'TV Reviews';\n\n    if ($prefix_subcategories) {\n        foreach ($temp as $key => $val) {\n            $parts = explode('-', $key);\n            $cat = $parts[0];\n            $subcat = $parts[1];\n\n            if ($subcat != '00') {\n                $temp[$key] = $temp[$cat.'-00'].' > '.$val;\n            }\n        }\n    }\n\n    return $temp;\n}\n"
  },
  {
    "path": "lib/http/curl.php",
    "content": "<?php\n\nnamespace Podlove\\Http;\n\nuse Podlove\\Log;\n\n/**\n * Wrapper for WordPress' WP_Http_Curl class.\n */\nclass Curl\n{\n    // WP_Http instance\n    private $curl;\n\n    // request call parameters\n    private $request = [];\n\n    private $response;\n\n    public function __construct()\n    {\n        $this->curl = new \\WP_Http();\n    }\n\n    public function request($url, $params = [])\n    {\n        $defaults = [\n            'user-agent' => self::user_agent(),\n            'stream' => false,\n            'decompress' => false,\n            'filename' => null,\n        ];\n\n        $params = wp_parse_args($params, $defaults);\n\n        if (!isset($params['_redirection']) || $params['_redirection']) {\n            if (!self::curl_can_follow_redirects()) {\n                $url = self::resolve_redirects($url, 5);\n            }\n        }\n\n        $this->response = $this->curl->request($url, $params);\n\n        if (is_wp_error($this->response)) {\n            Log::get()->addError('Curl error', [\n                'url' => $url,\n                'error' => $this->response->get_error_message(),\n            ]);\n        } elseif (substr($this->response['response']['code'], 0, 1) >= 4) {\n            Log::get()->addError('Curl error', [\n                'url' => $url,\n                'response code' => $this->response['response']['code'],\n                'debug' => $this->response,\n            ]);\n        }\n    }\n\n    public function isSuccessful()\n    {\n        return\n            $this->response               // request has been made\n            && !is_wp_error($this->response) // there was no error\n            && substr($this->response['response']['code'], 0, 1) < 4; // 1xx 2xx or 3xx\n    }\n\n    public function get_response()\n    {\n        return $this->response;\n    }\n\n    /**\n     * Podlove User Agent for cURL requests.\n     *\n     * @return string\n     */\n    public static function user_agent()\n    {\n        $curl_version = curl_version();\n\n        return sprintf(\n            'PHP/%s (; ) cURL/%s(OpenSSL/%s; zlib/%s) Wordpress/%s (; ) %s/%s (; )',\n            phpversion(),\n            $curl_version['version'],\n            $curl_version['ssl_version'],\n            $curl_version['libz_version'],\n            get_bloginfo('version'),\n            \\Podlove\\get_plugin_header('Name'),\n            \\Podlove\\get_plugin_header('Version')\n        );\n    }\n\n    /**\n     * Manually resolve redirects.\n     *\n     * Some server configurations can't deal with cURL CURLOPT_FOLLOWLOCATION\n     * setting. This method resolves a URL without using that setting.\n     *\n     * @param string $url               URL to resolve\n     * @param int    $maximum_redirects Maximum redirects. Default: 5.\n     *\n     * @return string Final URL\n     */\n    public static function resolve_redirects($url, $maximum_redirects = 5)\n    {\n        $curl = new Curl();\n        $curl->request($url, ['method' => 'HEAD', '_redirection' => 0]);\n        $response = $curl->get_response();\n\n        if (is_wp_error($response)) {\n            $message = sprintf('Unable to resolve URL. Might be related to PHP setting open_basedir, which should be empty, but is: \"%s\"', ini_get('open_basedir'));\n            Log::get()->addError($message, [\n                'url' => $url,\n                'error' => $response->get_error_message(),\n            ]);\n\n            return $url;\n        }\n\n        $http_code = $response['response']['code'];\n        $location = isset($response['headers']['location']) ? $response['headers']['location'] : null;\n\n        if ($http_code >= 300 && $http_code <= 400 && $location && $maximum_redirects > 0) {\n            return self::resolve_redirects($location, $maximum_redirects - 1);\n        }\n\n        return $url;\n    }\n\n    /**\n     * Check for CURLOPT_FOLLOWLOCATION bug.\n     *\n     * If open_basedir path is set,\n     * CURLOPT_FOLLOWLOCATION does not work.\n     *\n     * safe_mode is not checked because it was removed in PHP 5.4 and we require at least 5.4\n     *\n     * @see  https://stackoverflow.com/questions/2511410/curl-follow-location-error\n     * @see  https://stackoverflow.com/questions/19539922/php-can-curlopt-followlocation-and-open-basedir-be-used-together\n     *\n     * @return bool\n     */\n    public static function curl_can_follow_redirects()\n    {\n        return !ini_get('open_basedir');\n    }\n}\n"
  },
  {
    "path": "lib/jobs/counting_job.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\n/**\n * Counting Job.\n *\n * This is for development/debugging purposes only.\n * This job does not do anything besides incrementing a number.\n */\nclass CountingJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->state = $this->args['from'];\n    }\n\n    public static function title()\n    {\n        return __('Counter', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Can increment numbers like a boss.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function defaults()\n    {\n        return [\n            'from' => 0,\n            'to' => 100,\n            'stepsize' => 1,\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return $this->args['to'] - $this->args['from'];\n    }\n\n    protected function do_step()\n    {\n        $this->state += $this->args['stepsize'];\n        // generate CPU intensive task\n        // for ($i=0; $i < 7000000; $i++) {\n        // \tpow($i, 42);\n        // }\n    }\n}\n"
  },
  {
    "path": "lib/jobs/cron_job_runner.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Log;\nuse Podlove\\Model\\Job;\n\n/**\n * WP Cron based job runner.\n *\n * EXAMPLES\n *\n *     use \\Podlove\\Jobs\\CronJobRunner;\n *\n *     CronJobRunner::create_job('\\Podlove\\Jobs\\CountingJob', ['from' => 0, 'to' => '42']);\n */\nclass CronJobRunner\n{\n    /**\n     * @var float\n     */\n    public static $requestTime;\n\n    public static function init()\n    {\n        add_filter('cron_schedules', [__CLASS__, 'add_cron_schedules']);\n\n        if (!wp_next_scheduled('cron_job_worker')) {\n            wp_schedule_event(time(), '1min', 'cron_job_worker');\n        }\n        add_action('cron_job_worker', [__CLASS__, 'work_jobs']);\n    }\n\n    public static function add_cron_schedules($schedules)\n    {\n        $schedules['1min'] = [\n            'interval' => 60,\n            'display' => __('Every minute'),\n        ];\n\n        return $schedules;\n    }\n\n    /**\n     * Create new job.\n     *\n     * @param array $args\n     * @param mixed $job_class\n     */\n    public static function create_job($job_class, $args = [])\n    {\n        // for now, only accept one unfinished instance per job\n        // maybe make this behaviour configurable per job\n\n        $unfinished = Job::find_one_recent_unfinished_job($job_class);\n        if ($unfinished) {\n            \\Podlove\\Log::get()->addDebug('[job] did not start '.$job_class.' because a job of this type is already running (id '.$unfinished->id.')');\n\n            return null;\n        }\n\n        $job = (new $job_class($args))->init();\n\n        // immediately wake up worker for less waiting time\n        wp_schedule_single_event(time(), 'cron_job_worker');\n\n        \\Podlove\\Log::get()->addDebug('[job] [id '.$job->get_job()->id.'] start '.$job_class);\n\n        return $job;\n    }\n\n    public static function work_jobs($ignore_lock = false)\n    {\n        set_transient('podlove_jobs_last_spawn_worker', time(), DAY_IN_SECONDS);\n\n        if (!$ignore_lock && self::is_process_running()) {\n            return;\n        }\n\n        self::lock_process();\n\n        // find job to be done\n        $job = Job::find_next_in_queue();\n\n        if ($job) {\n            self::run_job($job->id, time());\n        } else {\n            self::unlock_process();\n        }\n    }\n\n    /**\n     * Maximum Seconds per Request.\n     *\n     * Duration after which no further job steps are started. The sum of\n     * max_seconds_per_request and lock_duration_buffer should not exceed\n     * PHP ini value `max_execution_time` which defaults to 30 on most systems.\n     *\n     * When running via the command line (or system cron), `max_execution_time`\n     * is often much higher or deactivated. In these cases, much higher execution\n     * times can be set for speedy results.\n     *\n     * Example cron call while overriding time limit:\n     *\n     *   sudo PODLOVE_JOB_MAX_SECONDS_PER_REQUEST=40 -u www-data php /var/www/html/wp/wp-cron.php >>/var/log/cron.log 2>&1\n     *\n     * @return int\n     */\n    public static function max_seconds_per_request()\n    {\n        $default = isset($_SERVER['PODLOVE_JOB_MAX_SECONDS_PER_REQUEST']) ? $_SERVER['PODLOVE_JOB_MAX_SECONDS_PER_REQUEST'] : 20;\n\n        return apply_filters('podlove_job_max_seconds_per_request', $default);\n    }\n\n    /**\n     * Lock Duration Buffer.\n     *\n     * A new job step is only started when the elapsed time is below\n     * max_seconds_per_request. However, a step might take a while to complete\n     * and exceed max_seconds_per_request. The buffer should be at least as big\n     * as the longest expected time for one step of any job to avoid two\n     * job processes running at the same time.\n     *\n     * @return int\n     */\n    public static function lock_duration_buffer()\n    {\n        $default = isset($_SERVER['PODLOVE_JOB_LOCK_DURATION_BUFFER']) ? $_SERVER['PODLOVE_JOB_LOCK_DURATION_BUFFER'] : 5;\n\n        return apply_filters('podlove_job_lock_duration_buffer', $default);\n    }\n\n    public static function run_job($job_id, $spawn_time)\n    {\n        set_transient('podlove_jobs_last_spawn_runner', time(), DAY_IN_SECONDS);\n\n        $job = Job::load($job_id);\n\n        // abort jobs that cannot finish\n        $created = strtotime($job->get_job()->created_at);\n        $diff = time() - $created;\n\n        if ($diff > HOUR_IN_SECONDS * 4) {\n            $job->get_job()->delete();\n            \\Podlove\\Log::get()->addWarning('[job] [id '.$job_id.'] \"'.$job->title().'\" aborted because it ran too long');\n            self::unlock_process();\n\n            return;\n        }\n\n        if (!$job) {\n            \\Podlove\\Log::get()->addDebug('[job] [id '.$job_id.'] runner tried to run job but it does not exist');\n            self::unlock_process();\n\n            return;\n        }\n\n        $job->get_job()->increase_wakeup_count();\n\n        while (!$job->is_finished() && self::should_run_another_job()) {\n            $job->step();\n            // \\Podlove\\Log::get()->addDebug('[job] [id ' . $job_id . '] step');\n        }\n\n        if ($job->is_finished()) {\n            \\Podlove\\Log::get()->addDebug('[job] [id '.$job_id.'] done');\n        }\n\n        $job->get_job()->increase_sleep_count();\n        self::unlock_process();\n\n        if (self::should_run_another_job()) {\n            self::work_jobs();\n        } else {\n            // after finishing, spawn a new worker process\n            wp_schedule_single_event(time(), 'cron_job_worker');\n        }\n    }\n\n    /**\n     * Determine if another job should run based on request duration.\n     *\n     * I tried getrusage() but it does not return useful time values. It looks\n     * like PHP processes can get reused, so requests don't reliably start with\n     * 0 seconds system/user time.\n     * The microtime approach is \"good enough\", just don't try to use the allowed\n     * 30 seconds and we should be fine.\n     *\n     * @return bool\n     */\n    public static function should_run_another_job()\n    {\n        $elapsed = microtime(true) - self::$requestTime;\n\n        return $elapsed < self::max_seconds_per_request();\n    }\n\n    /**\n     * Is process running.\n     *\n     * Check whether the current process is already running\n     * in a background process.\n     */\n    protected static function is_process_running()\n    {\n        if (get_transient('podlove_process_lock')) {\n            // Process already running.\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Lock process.\n     *\n     * Lock the process so that multiple instances can't run simultaneously.\n     * The duration should be greater than that defined in self::max_seconds_per_request().\n     */\n    protected static function lock_process()\n    {\n        $lock_duration = self::max_seconds_per_request() + self::lock_duration_buffer();\n        $lock_duration = apply_filters('podlove_queue_lock_time', $lock_duration);\n        set_transient('podlove_process_lock', microtime(), $lock_duration);\n    }\n\n    /**\n     * Unlock process.\n     *\n     * Unlock the process so that other instances can spawn.\n     *\n     * @return $this\n     */\n    protected static function unlock_process()\n    {\n        delete_transient('podlove_process_lock');\n    }\n}\n\nif (isset($_SERVER['REQUEST_TIME_FLOAT'])) {\n    CronJobRunner::$requestTime = $_SERVER['REQUEST_TIME_FLOAT'];\n} elseif (isset($_SERVER['REQUEST_TIME'])) {\n    CronJobRunner::$requestTime = $_SERVER['REQUEST_TIME'];\n} else {\n    CronJobRunner::$requestTime = microtime(true);\n}\n"
  },
  {
    "path": "lib/jobs/download_intent_cleanup_job.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Model;\n\nclass DownloadIntentCleanupJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->hooks['finished'] = [__CLASS__, 'purge_cache'];\n        $this->hooks['init'] = [$this, 'init_job'];\n    }\n\n    public static function title()\n    {\n        return __('Download Intent Cleanup', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Only cleaned download intents are available for analytics reports. Cleaning involves deduplication and removal of requests made by bots.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function mode($args)\n    {\n        if ($args['delete_all'] && $args['delete_all']) {\n            return __('From Scratch', 'podlove-podcasting-plugin-for-wordpress');\n        }\n\n        return __('Partial', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public function init_job()\n    {\n        if ($this->job->args['delete_all']) {\n            Model\\DownloadIntentClean::delete_all();\n            $this->job->state = ['previous_id' => 0];\n        } else {\n            $this->job->state = ['previous_id' => self::get_max_clean_intent_id()];\n        }\n    }\n\n    public static function defaults()\n    {\n        return [\n            'intents_total' => self::get_max_intent_id(),\n            'intents_per_step' => 100000,\n            'delete_all' => true, // delete all clean intents before starting\n        ];\n    }\n\n    public static function get_max_intent_id()\n    {\n        global $wpdb;\n\n        $id = $wpdb->get_var('SELECT MAX(id) FROM `'.Model\\DownloadIntent::table_name().'`');\n\n        return $id ? (int) $id : 0;\n    }\n\n    public static function get_max_clean_intent_id()\n    {\n        global $wpdb;\n\n        $id = $wpdb->get_var('SELECT MAX(id) FROM `'.Model\\DownloadIntentClean::table_name().'`');\n\n        return $id ? (int) $id : 0;\n    }\n\n    public function get_total_steps()\n    {\n        return $this->job->args['intents_total'];\n    }\n\n    public static function purge_cache()\n    {\n        \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n    }\n\n    protected function do_step()\n    {\n        global $wpdb;\n\n        $date_groupings = [\n            'daily' => '%%Y-%%m-%%d',\n            'hourly' => '%%Y-%%m-%%d %%H',\n        ];\n\n        $groupings_key = \\Podlove\\get_setting('tracking', 'window');\n\n        if ($date_groupings[$groupings_key]) {\n            $grouping = $date_groupings[$groupings_key];\n        } else {\n            $grouping = '%%Y-%%m-%%d %%H';\n        }\n\n        $sql = 'INSERT INTO `'.Model\\DownloadIntentClean::table_name().'` (`id`, `user_agent_id`, `media_file_id`, `request_id`, `accessed_at`, `source`, `context`, `geo_area_id`, `lat`, `lng`, `httprange`, `hours_since_release`)\n\t\tSELECT\n\t\t\tdi.id, `user_agent_id`, `media_file_id`, `request_id`, `accessed_at`, `source`, `context`, `geo_area_id`, `lat`, `lng`, `httprange`,\n\t\t\tTIMESTAMPDIFF(HOUR, p.post_date_gmt, accessed_at)\n\t\tFROM\n\t\t\t`'.Model\\DownloadIntent::table_name().'` di\n\t\t\tINNER JOIN '.Model\\MediaFile::table_name().' mf ON mf.id = di.media_file_id -- filter dead intents\n\t\t\tINNER JOIN '.Model\\Episode::table_name().\" e ON episode_id = e.id\n\t\t\tINNER JOIN {$wpdb->posts} p ON e.post_id = p.ID\n\t\tWHERE\n\t\t\tdi.accessed_at > p.post_date_gmt -- ignore pre-release intents\n\t\t\tAND user_agent_id NOT IN (SELECT id FROM `\".Model\\UserAgent::table_name().\"` WHERE bot) -- filter out bots\n\t\t\tAND di.id > %d AND di.id <= %d\n\t\t\tAND ((di.httprange != 'bytes=0-0' AND di.httprange != 'bytes=0-1') OR di.httprange IS NULL) -- filter out 1 byte requests; allow requests with empty httprange\n\t\tGROUP BY media_file_id, request_id, DATE_FORMAT(accessed_at, '{$grouping}') -- deduplication\n\t\t\";\n\n        $from = $this->job->state['previous_id'];\n        $to = $this->job->state['previous_id'] + $this->job->args['intents_per_step'];\n\n        $wpdb->query(\n            $wpdb->prepare($sql, $from, $to)\n        );\n\n        $this->job->update_state('previous_id', $to);\n\n        return $this->job->args['intents_per_step'];\n    }\n}\n"
  },
  {
    "path": "lib/jobs/download_timed_aggregator_job.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Model;\n\n/**\n * Aggregates downloads by time.\n */\nclass DownloadTimedAggregatorJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->hooks['init'] = [$this, 'setup_state'];\n    }\n\n    public static function title()\n    {\n        return __('Download Aggregation', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Recalculates sums and totals for episode downloads.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function mode($args)\n    {\n        if (isset($args['force']) && $args['force']) {\n            return __('From Scratch', 'podlove-podcasting-plugin-for-wordpress');\n        }\n\n        return __('Partial', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function defaults()\n    {\n        return [\n            'force' => false,\n        ];\n    }\n\n    public function setup_state()\n    {\n        $episodes = Model\\Podcast::get()->episodes();\n        $episode_ids = array_map(function ($e) {\n            return $e->id;\n        }, $episodes);\n\n        $this->job->state = [\n            'episode_ids' => $episode_ids, // reduced to empty array during job\n            'total_episodes' => count($episode_ids), // immutable\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return $this->job->state['total_episodes'];\n    }\n\n    /**\n     * Get \"in progress\" time group.\n     *\n     * For the first 24h after a release \"1d\" is returned, followed by \"2d\" etc.\n     * The next segment is only returned once the sum for the current segment\n     * has been calculated.\n     *\n     * @param array $item\n     *\n     * @return null|string\n     */\n    public static function current_time_group($item)\n    {\n        $groupings = self::groupings();\n        $hidden_groups = self::get_hidden_groups();\n\n        $group_keys = array_reverse(array_keys($groupings));\n\n        // return first column without downloads\n        foreach ($group_keys as $key) {\n            // ignore 'total' column\n            if ($key == 'total') {\n                continue;\n            }\n\n            if (in_array($key, $hidden_groups)) {\n                continue;\n            }\n\n            // ignore columns with calculated values\n            if (isset($item[$key]) && $item[$key] > 0) {\n                continue;\n            }\n\n            // ignore old segments without tracking\n            $two_days_ago = time() - DAY_IN_SECONDS * 2;\n            $segment_end_time = strtotime($item['post_date']) + $groupings[$key] * HOUR_IN_SECONDS;\n\n            if ($segment_end_time < $two_days_ago) {\n                continue;\n            }\n\n            return $key;\n        }\n\n        return null;\n    }\n\n    public static function get_hidden_groups_key()\n    {\n        return 'managepodlove_page_podlove_analyticscolumnshidden';\n    }\n\n    public static function get_hidden_groups()\n    {\n        return get_user_meta(get_current_user_id(), self::get_hidden_groups_key(), true);\n    }\n\n    public static function groupings()\n    {\n        return [\n            'total' => null,\n            '3y' => 24 * 7 * 52 * 3,\n            '2y' => 24 * 7 * 52 * 2,\n            '1y' => 24 * 7 * 52,\n            '3q' => 24 * 7 * 13 * 3,\n            '2q' => 24 * 7 * 13 * 2,\n            '1q' => 24 * 7 * 13,\n            '4w' => 24 * 7 * 4,\n            '3w' => 24 * 7 * 3,\n            '2w' => 24 * 7 * 2,\n            '1w' => 24 * 7,\n            '6d' => 24 * 6,\n            '5d' => 24 * 5,\n            '4d' => 24 * 4,\n            '3d' => 24 * 3,\n            '2d' => 24 * 2,\n            '1d' => 24,\n        ];\n    }\n\n    protected function do_step()\n    {\n        $state = $this->job->state;\n        $episode_id = array_pop($state['episode_ids']);\n        $this->job->state = $state;\n\n        $episode = Model\\Episode::find_by_id($episode_id);\n\n        if (!$episode) {\n            return 1;\n        }\n\n        $max_hsr = Model\\DownloadIntentClean::actual_episode_age_in_hours($episode_id);\n        $groupings = self::groupings();\n\n        foreach ($groupings as $key => $hours) {\n            if ($this->should_calculate_grouping($episode_id, $key, $hours, $max_hsr)) {\n                self::calculate_single_aggregation($episode, $key, $hours);\n            }\n        }\n\n        return 1;\n    }\n\n    private function should_calculate_grouping($episode_id, $group_key, $group_hours, $max_hsr)\n    {\n        // skip fields that cannot be calculated yet\n        if ($max_hsr <= $group_hours) {\n            return false;\n        }\n\n        // always calculate if enforced\n        if ($this->job->args['force']) {\n            return true;\n        }\n\n        // always calculate totals\n        if ($group_key === 'total') {\n            return true;\n        }\n\n        // skip if field is already calculated\n        return !((bool) get_post_meta($episode_id, '_podlove_downloads_'.$group_key, true));\n    }\n\n    private function calculate_single_aggregation($episode, $grouping_key, $grouping_hours)\n    {\n        global $wpdb;\n\n        $sql = 'SELECT\n\t\t\t  COUNT(*)\n\t\t\tFROM '.Model\\DownloadIntentClean::table_name().' di\n\t\t\tINNER JOIN '.Model\\MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\tINNER JOIN '.Model\\Episode::table_name().' e ON mf.episode_id = e.id\n\t\t\tWHERE e.id = %d';\n        $sql_params = [$episode->id];\n\n        if ($grouping_hours && $grouping_hours > 0) {\n            $sql .= ' AND hours_since_release <= %d';\n            $sql_params[] = $grouping_hours;\n        }\n\n        $downloads = $wpdb->get_var($wpdb->prepare($sql, $sql_params));\n\n        if ($downloads && is_numeric($downloads)) {\n            update_post_meta($episode->post_id, '_podlove_downloads_'.$grouping_key, $downloads);\n        }\n    }\n}\n"
  },
  {
    "path": "lib/jobs/job_cleaner.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Model\\Job;\n\nclass JobCleaner\n{\n    public static function init()\n    {\n        add_action('podlove_jobs_clean', [__CLASS__, 'podlove_jobs_clean']);\n\n        if (!wp_next_scheduled('podlove_jobs_clean')) {\n            wp_schedule_event(time(), 'hourly', 'podlove_jobs_clean');\n        }\n    }\n\n    public static function podlove_jobs_clean()\n    {\n        Job::clean();\n    }\n}\n"
  },
  {
    "path": "lib/jobs/job_trait.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Model\\Job;\n\ntrait JobTrait\n{\n    protected $hooks = [];\n\n    /**\n     * @var Podlove\\Model\\Job\n     */\n    protected $job;\n\n    public function __construct($args = [], $job = null)\n    {\n        $this->job = is_null($job) ? new Job() : $job;\n        $this->job->args = wp_parse_args($args, self::defaults());\n        $this->setup();\n    }\n\n    /**\n     * Called once on class construction.\n     *\n     * Does nothing by default. Override for custom setup behaviour.\n     */\n    public function setup() {}\n\n    /**\n     * Human readable title.\n     *\n     * @return string\n     */\n    public static function title()\n    {\n        return '';\n    }\n\n    /**\n     * Human readable job mode.\n     *\n     * Should be displayed with title to distinguish different job setups.\n     *\n     * @param array $args job arguments\n     *\n     * @return string\n     */\n    public static function mode($args)\n    {\n        return '';\n    }\n\n    /**\n     * Human readable description of what the job does.\n     *\n     * @return string\n     */\n    public static function description()\n    {\n        return '';\n    }\n\n    /**\n     * Return default job arguments.\n     *\n     * @return array\n     */\n    public static function defaults()\n    {\n        return [];\n    }\n\n    /**\n     * If a job is unique, only one can be active at any point in time.\n     *\n     * @todo  needs to be checked by job runner\n     *\n     * @return bool\n     */\n    public static function is_unique()\n    {\n        return true;\n    }\n\n    /**\n     * Initialize job.\n     *\n     * - find out and persist how many steps there are\n     *\n     * @trait\n     */\n    public function init()\n    {\n        if (isset($this->hooks['init'])) {\n            call_user_func($this->hooks['init']);\n        }\n\n        $this->job->steps_total = $this->get_total_steps();\n\n        if (!$this->job->steps_progress) {\n            $this->job->steps_progress = 0;\n        }\n\n        if (!$this->job->active_run_time) {\n            $this->job->active_run_time = 0;\n        }\n\n        $this->job->wakeups = $this->job->wakeups ? $this->job->wakeups : 0;\n        $this->job->sleeps = $this->job->sleeps ? $this->job->sleeps : 0;\n\n        $this->save_job();\n\n        return $this;\n    }\n\n    public function get_job_id()\n    {\n        return $this->job->id;\n    }\n\n    public function is_finished()\n    {\n        return $this->job->is_finished();\n    }\n\n    public function save_job()\n    {\n        $current_time = current_time('mysql', true);\n\n        if ($this->job->is_new()) {\n            $this->job->class = str_replace('\\\\', '\\\\\\\\', get_called_class());\n            $this->job->created_at = $current_time;\n        }\n\n        $this->job->updated_at = $current_time;\n\n        $this->job->save();\n    }\n\n    public function get_job()\n    {\n        return $this->job;\n    }\n\n    /**\n     * How many steps does it take to complete the job?\n     *\n     * @return int\n     */\n    abstract public function get_total_steps();\n\n    /**\n     * Do one step, and record the progress.\n     */\n    public function step()\n    {\n        $start = microtime(true);\n        $progress = $this->do_step();\n        $end = microtime(true);\n        $this->log_active_run_time($end - $start);\n\n        $this->job->steps_progress += ($progress > 0) ? $progress : 1;\n        $this->save_job();\n\n        if ($this->is_finished() && isset($this->hooks['finished'])) {\n            call_user_func($this->hooks['finished']);\n        }\n    }\n\n    protected function get_status_percent()\n    {\n        if (!$this->status['total']) {\n            return null;\n        }\n\n        return round($this->status['progress'] / $this->status['total'] * 100, 2);\n    }\n\n    protected function get_status_text()\n    {\n        if ($this->status['progress'] === 0) {\n            return 'not_started';\n        }\n        if (!$this->is_finished()) {\n            return 'running';\n        }\n\n        return 'done';\n    }\n\n    /**\n     * Implement one step of the job.\n     *\n     * @return int How much progress did the step make?\n     */\n    abstract protected function do_step();\n\n    private function log_active_run_time($time_ms)\n    {\n        $this->job->active_run_time += $time_ms;\n    }\n}\n"
  },
  {
    "path": "lib/jobs/request_id_rehash_job.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nclass RequestIdRehashJob\n{\n    use JobTrait;\n\n    public static function title()\n    {\n        return __('Rehash Tracking Request IDs', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Improve request id anonymity for DSVGO.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function defaults()\n    {\n        return [\n            'intents_total' => podlove_rehash_total_remaining(self::downloads_table_name()),\n            'ids_per_step' => 1000,\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return $this->job->args['intents_total'];\n    }\n\n    protected function do_step()\n    {\n        global $wpdb;\n\n        $request_ids = podlove_rehash_fetch_some_request_ids(\n            self::downloads_table_name(),\n            $this->job->args['ids_per_step']\n        );\n\n        foreach ($request_ids as $request_id) {\n            podlove_rehash_replace_request_id(self::downloads_table_name(), $request_id);\n        }\n\n        return $this->job->args['ids_per_step'];\n    }\n\n    private static function downloads_table_name()\n    {\n        return \\Podlove\\Model\\DownloadIntent::table_name();\n    }\n}\n"
  },
  {
    "path": "lib/jobs/tools_section.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nclass ToolsSection\n{\n    public static function init()\n    {\n        add_action(\n            'init',\n            fn () => \\Podlove\\add_tools_section('jobs', __('Background Jobs', 'podlove-podcasting-plugin-for-wordpress'), [__CLASS__, 'render_jobs_overview'])\n        );\n\n        ToolsSectionCronDiagnostics::init();\n    }\n\n    public static function render_jobs_overview()\n    {\n        ?>\n\t\t\t<div id=\"podlove-tools-dashboard\"><jobs-dashboard></jobs-dashboard></div>\n\t\t<?php\n\n        $activities = [\n            'worker' => [\n                'title' => 'Worker Activity',\n                'activity' => get_transient('podlove_jobs_last_spawn_worker'),\n                'description' => 'Should not be more than two or three minutes.',\n            ],\n            'runner' => [\n                'title' => 'Runner Activity',\n                'activity' => get_transient('podlove_jobs_last_spawn_runner'),\n                'description' => 'May be inactive if no jobs are running. If at least one job is running, should not be more than two or three minutes.',\n            ],\n        ];\n\n        echo '<p>';\n        foreach ($activities as $activity) {\n            echo $activity['title'].': ';\n\n            if (!$activity['activity']) {\n                echo 'Not in the last hour.';\n            } else {\n                $seconds = time() - $activity['activity'];\n                if ($seconds === 0) {\n                    echo __('now');\n                } elseif ($seconds < 120) {\n                    echo sprintf(_n('%s second ago', '%s seconds ago', $seconds), $seconds);\n                } else {\n                    echo sprintf(__('%s ago'), human_time_diff($activity['activity'], time()));\n                }\n            }\n\n            if ($activity['description']) {\n                echo \" <small><em>({$activity['description']})</em></small>\";\n            }\n\n            echo '<br>';\n        }\n        echo '</p>';\n\n        do_action('podlove_jobs_tools_end');\n    }\n}\n"
  },
  {
    "path": "lib/jobs/tools_section_cron_diagnostics.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nclass ToolsSectionCronDiagnostics\n{\n    public static function init()\n    {\n        add_action('podlove_jobs_tools_end', [__CLASS__, 'view']);\n\n        add_action('wp_ajax_podlove-cron-diag-start', [__CLASS__, 'diagnosis_start']);\n        add_action('wp_ajax_podlove-cron-diag-check', [__CLASS__, 'diagnosis_check']);\n        add_action('podlove_cron_diagnosis_cron', [__CLASS__, 'register_cron_executed']);\n    }\n\n    public static function diagnosis_start()\n    {\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        update_option('podlove_cron_diagnosis', 'started');\n        update_option('podlove_cron_diagnosis_tries', 0);\n        wp_schedule_single_event(time(), 'podlove_cron_diagnosis_cron');\n        \\Podlove\\AJAX\\Ajax::respond_with_json(['success' => true]);\n    }\n\n    public static function diagnosis_check()\n    {\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $tries = get_option('podlove_cron_diagnosis_tries', 0);\n        update_option('podlove_cron_diagnosis_tries', $tries + 1);\n        \\Podlove\\AJAX\\Ajax::respond_with_json([\n            'tries' => $tries + 1,\n            'success' => get_option('podlove_cron_diagnosis') == 'executed',\n        ]);\n    }\n\n    public static function register_cron_executed()\n    {\n        update_option('podlove_cron_diagnosis', 'executed');\n    }\n\n    public static function view()\n    {\n        $cron_constants = [\n            'ALTERNATE_WP_CRON',\n            'DISABLE_WP_CRON',\n        ];\n        $cron_constants = array_map(function ($constant) {\n            return $constant.': '.(defined($constant) ? (constant($constant) ? 'on' : 'off') : 'not defined');\n        }, $cron_constants); ?>\n\n\t\t<div id=\"podlove-cron-diagnosis-teaser\">\n\t\t\tJobs not working properly? <button class=\"button\" id=\"podlove-cron-diagnosis\">Run WP Cron Diagnosis</button>\n\t\t</div>\n\n\t\t<div id=\"podlove-cron-diagnosis-wrapper\">\n\t\t\t<h4>WP Cron Diagnostics</h4>\n\n\t\t\t<p>\n\t\t\t\t<strong>PHP Constants</strong>\n\t\t\t\t<code style=\"display: block\">\n\t\t\t\t\t<?php echo implode('<br>', $cron_constants); ?>\n\t\t\t\t</code>\n\t\t\t</p>\n\n\n\t\t\t<ul>\n\t\t\t\t<li id=\"diagnosis-item-reach-wp-cron\">\n\t\t\t\t\tIs <code><?php echo esc_html(site_url('wp-cron.php')); ?></code> accessible? <i class=\"podlove-icon-spinner rotate\" style=\"display: none\"></i> <span class=\"result\"></span>\n\t\t\t\t</li>\n\t\t\t\t<li id=\"diagnosis-item-check-cron-exec\">\n\t\t\t\t\tAre scheduled crons run? <i class=\"podlove-icon-spinner rotate\" style=\"display: none\"></i> <span class=\"result\"></span>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</div>\n\n<script type=\"text/javascript\">\n(function($) {\n\n\tvar diagnosisButton = $(\"#podlove-cron-diagnosis\");\n\n\tvar initReachWpCron = function() {\n\t\tvar taskWrapper = $(\"#diagnosis-item-reach-wp-cron\");\n\t\tvar cronUrl = '<?php echo esc_js(site_url('wp-cron.php')); ?>';\n\t\tvar spinner = taskWrapper.find('i.podlove-icon-spinner');\n\n\t\tspinner.show();\n\n\t\t$.ajax({\n\t\t\turl: cronUrl,\n\t\t}).done(function (data, textStatus, jqXHR) {\n\t\t\ttaskWrapper.find(\".result\").html(\"Yes, good! <i class=\\\"podlove-icon-ok\\\"></i>\");\n\t\t}).fail(function (data, textStatus, jqXHR) {\n\t\t\ttaskWrapper.find(\".result\").html(\"ERROR! \" + data.status + \" \" + data.textStatus + \" <i class=\\\"podlove-icon-remove\\\"></i>\");\n\t\t}).always(function() {\n\t\t\tspinner.hide();\n\t\t})\n\t\t;\n\t}\n\n\tvar initLookForCronSuccess = function() {\n\t\tvar taskWrapper = $(\"#diagnosis-item-check-cron-exec\");\n\t\tvar result = taskWrapper.find(\".result\");\n\t\tvar helpHtml = 'There are many reasons why WP Cron may not work. <a href=\"https://encrypted.google.com/search?hl=en&q=wordpress%20cron%20not%20working\" target=\"_blank\">Try this Google search to find out why.</a>';\n\t\tvar maxAttempts = 30;\n\t\tvar spinner = taskWrapper.find('i.podlove-icon-spinner');\n\n\t\t$.ajax({\n\t\t\turl: ajaxurl,\n\t\t\tdata: {\n\t\t\t\taction: 'podlove-cron-diag-check',\n\t\t\t\tnonce: podlove_admin_global.nonce_ajax\n\t\t\t}\n\t\t}).always(function(data) {\n\t\t\tif (data && data.success) {\n\t\t\t\tresult.html(\"Yes, good! <i class=\\\"podlove-icon-ok\\\"></i>\");\n\t\t\t\tspinner.hide();\n\t\t\t} else {\n\t\t\t\tif (data && data.tries > maxAttempts) {\n\t\t\t\t\tresult.html(\"Sorry, it looks like WP Cron is not working. \" + helpHtml + \" <i class=\\\"podlove-icon-remove\\\"></i>\");\n\t\t\t\t\tspinner.hide();\n\t\t\t\t} else if (data && data.tries > 4) {\n\t\t\t\t\tresult.html(\"Hmm, this is taking longer than expected. \" + data.tries + \"/\" + maxAttempts + \" failed attempts so far.\");\n\t\t\t\t\twindow.setTimeout(initLookForCronSuccess, 2500);\n\t\t\t\t} else if (data && data.tries) {\n\t\t\t\t\twindow.setTimeout(initLookForCronSuccess, 2500);\n\t\t\t\t} else {\n\t\t\t\t\tresult.html(\"Something unexpected went wrong. \" + helpHtml + \" <i class=\\\"podlove-icon-remove\\\"></i>\");\n\t\t\t\t\tspinner.hide();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t};\n\n\tvar initExecWpCron = function() {\n\t\tvar taskWrapper = $(\"#diagnosis-item-check-cron-exec\");\n\n\t\ttaskWrapper.find(\"i\").show();\n\n\t\t$.ajax({\n\t\t\turl: ajaxurl,\n\t\t\tdata: {\n\t\t\t\taction: 'podlove-cron-diag-start',\n\t\t\t\tnonce: podlove_admin_global.nonce_ajax\n\t\t\t}\n\t\t}).always(function() {\n\t\t\tinitLookForCronSuccess();\n\t\t});\n\t};\n\n\tvar initDiagnosis = function() {\n\n\t\t// hide teaser\n\t\t$(\"#podlove-cron-diagnosis-teaser\").hide(400);\n\n\t\t// show diagnosis\n\t\t$(\"#podlove-cron-diagnosis-wrapper\").show(400);\n\n\t\t// start diagnosis\n\t\tinitReachWpCron();\n\t\tinitExecWpCron();\n\t};\n\n\tdiagnosisButton.on('click', initDiagnosis)\n\n}(jQuery));\n</script>\n<style type=\"text/css\">\nli span.result { font-style: italic; }\n#podlove-cron-diagnosis-teaser { line-height: 28px; }\n#podlove-cron-diagnosis-wrapper { display: none; }\n</style>\n\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/jobs/user_agent_refresh_job.php",
    "content": "<?php\n\nnamespace Podlove\\Jobs;\n\nuse Podlove\\Model\\DownloadIntentClean;\nuse Podlove\\Model\\UserAgent;\n\nclass UserAgentRefreshJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->hooks['finished'] = [__CLASS__, 'delete_bots_from_clean_downloadintents'];\n\n        if (!$this->job->state) {\n            $this->job->state = ['previous_id' => 0];\n        }\n    }\n\n    public static function title()\n    {\n        return __('User Agent Refresh', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Updates user agent metadata based on device-detector library.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function defaults()\n    {\n        return [\n            'agents_total' => UserAgent::count(),\n            'agents_per_step' => 500,\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return $this->job->args['agents_total'];\n    }\n\n    public static function delete_bots_from_clean_downloadintents()\n    {\n        global $wpdb;\n\n        $sql = 'DELETE FROM `'.DownloadIntentClean::table_name().'` WHERE `user_agent_id` IN (\n\t\t\tSELECT id FROM `'.UserAgent::table_name().'` ua WHERE ua.bot\n\t\t)';\n\n        $wpdb->query($sql);\n    }\n\n    protected function do_step()\n    {\n        $previous_id = (int) $this->job->state['previous_id'];\n        $agents_per_step = (int) $this->job->args['agents_per_step'];\n\n        $agents = UserAgent::find_all_by_where(sprintf('id > %d ORDER BY id ASC LIMIT %d', $previous_id, $agents_per_step));\n\n        $progress = 0;\n        foreach ($agents as $ua) {\n            $ua->parse()->save();\n            ++$progress;\n        }\n\n        $this->job->update_state('previous_id', $ua->id);\n\n        return $progress;\n    }\n}\n"
  },
  {
    "path": "lib/list_table.php",
    "content": "<?php\n\nnamespace Podlove;\n\nif (!class_exists('WP_List_Table')) {\n    require_once ABSPATH.'wp-admin/includes/class-wp-list-table.php';\n}\n\n/**\n * Extend WordPress WP_List_Table by some functionality.\n */\nclass List_Table extends \\WP_List_Table\n{\n    /**\n     * Override display of empty list table.\n     *\n     * Display \"Add New\" link directly in the table.\n     */\n    public function no_items()\n    {\n        ?>\n\t\t<div style=\"margin: 20px 10px 10px 5px\">\n\t\t\t<?php $this->no_items_content(); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function no_items_content()\n    {\n        $podlove_tab = htmlspecialchars($_REQUEST['podlove_tab'] ?? '');\n        $page = htmlspecialchars($_REQUEST['page'] ?? '');\n\n        $url = sprintf('?page=%s&action=%s', $page, 'new');\n        $url .= !empty($podlove_tab) ? '&amp;podlove_tab='.$podlove_tab : ''; ?>\n\t\t<span>\n\t\t\t<?php _e('No items found.'); ?>\n\t\t</span>\n\t\t<a href=\"<?php echo esc_attr($url); ?>\" class=\"add-new-h2\">\n\t\t\t<?php _e('Add New'); ?>\n\t\t</a>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/log.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse PodlovePublisher_Vendor\\Monolog\\Handler\\ErrorLogHandler;\nuse PodlovePublisher_Vendor\\Monolog\\Logger;\n\n/**\n * Podlove Logger class.\n *\n * @see  https://github.com/Seldaek/monolog for documentation\n *\n * When to use what kind of log message?\n * - DEBUG: Detailed debug information.\n * - INFO: Interesting events. Examples: User logs in, SQL logs.\n * - WARNING: Exceptional occurrences that are not errors. Examples: Use of deprecated APIs, poor use of an API, undesirable things that are not necessarily wrong.\n * - ERROR: Runtime errors that do not require immediate action but should typically be logged and monitored.\n * - CRITICAL: Critical conditions. Example: Application component unavailable, unexpected exception.\n * - ALERT: Action must be taken immediately. Example: Entire website down, database unavailable, etc. This should trigger the SMS alerts and wake you up.\n *\n * Example usage:\n *   use Podlove\\Log;\n *\n *   Log::get()->addWarning( 'This is a warning.' );\n *   Log::get()->addWarning( 'This is another warning.', array( 'comment' => 'additional info' ) );\n */\nclass Log\n{\n    private static $instance;\n    private $log;\n\n    private function __construct()\n    {\n        $log = new Logger('Podlove');\n        if ($this->is_debug_enabled()) {\n            $log->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $this->get_log_level()));\n        }\n\n        $this->log = $log;\n    }\n\n    /**\n     * Proxy calls to Logger instance.\n     *\n     * @param string $name      method name\n     * @param array  $arguments\n     */\n    public function __call($name, $arguments)\n    {\n        // proxy deprecated monolog function names\n        // TODO: replace all add* calls in code\n        if ($name == 'addWarning') {\n            $name = 'warning';\n        }\n        if ($name == 'addInfo') {\n            $name = 'info';\n        }\n        if ($name == 'addError') {\n            $name = 'error';\n        }\n        if ($name == 'addDebug') {\n            $name = 'debug';\n        }\n\n        if (method_exists($this->log, $name)) {\n            call_user_func_array([$this->log, $name], $arguments);\n        }\n    }\n\n    public function __clone()\n    {\n        trigger_error('Singleton. Cloning not allowed.', E_USER_ERROR);\n    }\n\n    public function __wakeup()\n    {\n        trigger_error('Singleton. Deserialisation not allowed.', E_USER_ERROR);\n    }\n\n    public static function get()\n    {\n        if (!isset(self::$instance)) {\n            self::$instance = new self();\n        }\n\n        return self::$instance;\n    }\n\n    public function get_log_level()\n    {\n        if (defined('PODLOVE_LOG_LEVEL')) {\n            return constant('PODLOVE_LOG_LEVEL');\n        }\n\n        return $this->is_debug_enabled() ? Logger::DEBUG : Logger::INFO;\n    }\n\n    public function is_debug_enabled()\n    {\n        return defined('PODLOVE_LOGGER_DEBUG') && constant('PODLOVE_LOGGER_DEBUG');\n    }\n}\n"
  },
  {
    "path": "lib/model/asset_assignment.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Simplified Singleton model for episode assignment data.\n */\nclass AssetAssignment\n{\n    /**\n     * Contains property names.\n     *\n     * @var array\n     */\n    protected static $properties = [];\n\n    /**\n     * Contains property values.\n     *\n     * @var array\n     */\n    private $data = [];\n\n    protected function __construct()\n    {\n        $this->fetch();\n    }\n\n    private function __clone() {}\n\n    public function __set($name, $value)\n    {\n        if ($this->has_property($name)) {\n            $this->set_property($name, $value);\n        } else {\n            $this->{$name} = $value;\n        }\n    }\n\n    public function __get($name)\n    {\n        if ($this->has_property($name)) {\n            return $this->get_property($name);\n        }\n\n        return $this->{$name};\n    }\n\n    /**\n     * @return \\Podlove\\Model\\AssetAssignment\n     */\n    public static function get_instance()\n    {\n        return new self();\n    }\n\n    /**\n     * Does the given property exist?\n     *\n     * @param string $name name of the property to test\n     *\n     * @return bool true if the property exists, else false\n     */\n    public function has_property($name)\n    {\n        return in_array($name, $this->property_names());\n    }\n\n    /**\n     * Return a list of property names.\n     *\n     * @return array property names\n     */\n    public function property_names()\n    {\n        return array_map(function ($p) {\n            return $p['name'];\n        }, self::$properties);\n    }\n\n    /**\n     * Define a property with by name.\n     *\n     * @param string $name Name of the property / column\n     */\n    public static function property($name)\n    {\n        if (!isset(self::$properties)) {\n            self::$properties = [];\n        }\n\n        array_push(self::$properties, ['name' => $name]);\n    }\n\n    /**\n     * Save current state to database.\n     */\n    public function save()\n    {\n        update_option('podlove_asset_assignment', $this->data);\n    }\n\n    /**\n     * Generate a human readable title.\n     *\n     * Return name and, if available, the subtitle. Separated by a dash.\n     *\n     * @return string\n     */\n    public function full_title()\n    {\n        $t = $this->title;\n\n        if ($this->subtitle) {\n            $t = $t.' - '.$this->subtitle;\n        }\n\n        return $t;\n    }\n\n    private function set_property($name, $value)\n    {\n        $this->data[$name] = $value;\n    }\n\n    private function get_property($name)\n    {\n        if (isset($this->data[$name])) {\n            return $this->data[$name];\n        }\n\n        return null;\n    }\n\n    /**\n     * Return a list of property dictionaries.\n     *\n     * @return array property list\n     */\n    private function properties()\n    {\n        if (!isset(self::$properties)) {\n            self::$properties = [];\n        }\n\n        return self::$properties;\n    }\n\n    /**\n     * Load podcast data.\n     */\n    private function fetch()\n    {\n        $this->data = get_option('podlove_asset_assignment', []);\n    }\n}\n\nAssetAssignment::property('image');\nAssetAssignment::property('chapters');\nAssetAssignment::property('transcript');\n"
  },
  {
    "path": "lib/model/base.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nabstract class Base\n{\n    /**\n     * Property dictionary for all tables.\n     *\n     * @todo refactor into properties for current table only via late static binding\n     */\n    private static $properties = [];\n\n    private $is_new = true;\n\n    /**\n     * Contains property values.\n     */\n    private $data = [];\n\n    public function __set($name, $value)\n    {\n        if (self::has_property($name)) {\n            $this->set_property($name, $value);\n        } else {\n            $this->{$name} = $value;\n        }\n    }\n\n    public function __get($name)\n    {\n        if (self::has_property($name)) {\n            return $this->get_property($name);\n        }\n        if (property_exists($this, $name)) {\n            return $this->{$name};\n        }\n\n        return null;\n    }\n\n    // mimic ::find_one_by_<property>\n    // mimic ::find_all_by_<property>\n    public static function __callStatic($name, $arguments)\n    {\n        $property = preg_replace_callback(\n            '/^find_one_by_(\\w+)$/',\n            function ($p) {\n                return $p[1];\n            },\n            $name\n        );\n\n        if ($property !== $name) {\n            return self::find_one_by_property($property, $arguments[0]);\n        }\n\n        $property = preg_replace_callback(\n            '/^find_all_by_(\\w+)$/',\n            function ($p) {\n                return $p[1];\n            },\n            $name\n        );\n\n        if ($property !== $name) {\n            return self::find_all_by_property($property, $arguments[0]);\n        }\n\n        throw new \\Exception(\"Fatal Error: Call to unknown static method {$name}.\");\n    }\n\n    /**\n     * Define a property with name and type.\n     *\n     * Currently only supports basics.\n     *\n     * @todo enable additional options like NOT NULL, DEFAULT etc.\n     *\n     * @param string $name Name of the property / column\n     * @param string $type mySQL column type\n     * @param mixed  $args\n     */\n    public static function property($name, $type, $args = [])\n    {\n        $class = get_called_class();\n\n        if (!isset(self::$properties[$class])) {\n            self::$properties[$class] = [];\n        }\n\n        // \"id\" columns and those ending on \"_id\" get an index by default\n        $index = $name == 'id' || stripos($name, '_id');\n        // but if the argument is set, it overrides the default\n        if (isset($args['index'])) {\n            $index = $args['index'];\n        }\n\n        self::$properties[$class][] = [\n            'name' => $name,\n            'type' => $type,\n            'index' => $index,\n            'index_length' => isset($args['index_length']) ? $args['index_length'] : null,\n            'unique' => isset($args['unique']) ? $args['unique'] : null,\n        ];\n    }\n\n    /**\n     * Does the given property exist?\n     *\n     * @param string $name name of the property to test\n     *\n     * @return bool true if the property exists, else false\n     */\n    public static function has_property($name)\n    {\n        return in_array($name, self::property_names());\n    }\n\n    /**\n     * Return a list of property names.\n     *\n     * @return array property names\n     */\n    public static function property_names()\n    {\n        return array_map(function ($p) {\n            return $p['name'];\n        }, self::properties());\n    }\n\n    /**\n     * Does the table have any entries?\n     *\n     * @return bool true if there is at least one entry, else false\n     */\n    public static function has_entries()\n    {\n        return self::count() > 0;\n    }\n\n    /**\n     * Return number of rows in the table.\n     *\n     * @return int number of rows\n     */\n    public static function count()\n    {\n        global $wpdb;\n\n        $sql = 'SELECT COUNT(*) FROM '.static::table_name();\n\n        return (int) $wpdb->get_var($sql);\n    }\n\n    public static function find_by_id($id)\n    {\n        $value = wp_cache_get(static::cache_key($id), 'podlove-model');\n\n        if ($value === false) {\n            $value = self::find_one_by_sql(\n                'SELECT * FROM '.static::table_name().' WHERE id = '.(int) $id\n            );\n            wp_cache_set(static::cache_key($id), $value, 'podlove-model');\n        }\n\n        return $value;\n    }\n\n    /**\n     * Get unique cache key for data row.\n     *\n     * @param int $id object id\n     *\n     * @return string cache key\n     */\n    public static function cache_key($id)\n    {\n        return 'podlove_'.static::table_name().'_id'.$id;\n    }\n\n    public static function find_all_by_property($property, $value)\n    {\n        return self::find_all_by_sql(\n            'SELECT * FROM '.static::table_name().' WHERE '.$property.' = \\''.esc_sql($value).'\\''\n        );\n    }\n\n    public static function find_one_by_property($property, $value)\n    {\n        return self::find_one_by_sql(\n            'SELECT * FROM '.static::table_name().' WHERE '.$property.' = \\''.esc_sql($value).'\\' LIMIT 0,1'\n        );\n    }\n\n    public static function find_all_by_where($where)\n    {\n        return self::find_all_by_sql(\n            'SELECT * FROM '.static::table_name().' WHERE '.$where\n        );\n    }\n\n    public static function find_one_by_where($where)\n    {\n        return self::find_one_by_sql(\n            'SELECT * FROM '.static::table_name().' WHERE '.$where.' LIMIT 0,1'\n        );\n    }\n\n    /**\n     * Retrieve first item from the table.\n     *\n     * @return model object\n     */\n    public static function first()\n    {\n        return self::find_one_by_sql(\n            'SELECT * FROM '.static::table_name().' LIMIT 0,1'\n        );\n    }\n\n    public static function last()\n    {\n        return self::find_one_by_sql(\n            'SELECT * FROM '.static::table_name().' ORDER BY id DESC LIMIT 0,1'\n        );\n    }\n\n    /**\n     * Retrieve all entries from the table.\n     *\n     * @param string $sql_suffix optional SQL, appended after FROM clause\n     *\n     * @return array list of model objects\n     */\n    public static function all($sql_suffix = '')\n    {\n        return self::find_all_by_sql(\n            'SELECT * FROM '.static::table_name().' '.$sql_suffix\n        );\n    }\n\n    /**\n     * True if not yet saved to database. Else false.\n     */\n    public function is_new()\n    {\n        return $this->is_new;\n    }\n\n    public function flag_as_not_new()\n    {\n        $this->is_new = false;\n    }\n\n    /**\n     * Rails-ish update_attributes for easy form handling.\n     *\n     * Takes an array of form values and takes care of serializing it.\n     *\n     * @param array $attributes\n     *\n     * @return bool\n     */\n    public function update_attributes($attributes)\n    {\n        if (!is_array($attributes)) {\n            return false;\n        }\n\n        foreach ($attributes as $key => $value) {\n            $this->{$key} = $value;\n        }\n\n        if (isset($_REQUEST['checkboxes']) && is_array($_REQUEST['checkboxes'])) {\n            foreach ($_REQUEST['checkboxes'] as $checkbox) {\n                if (isset($attributes[$checkbox]) && $attributes[$checkbox] === 'on') {\n                    $this->{$checkbox} = 1;\n                } else {\n                    $this->{$checkbox} = 0;\n                }\n            }\n        }\n\n        return $this->save();\n    }\n\n    /**\n     * Update and save a single attribute.\n     *\n     * @param string $attribute attribute name\n     * @param mixed  $value\n     *\n     * @return (bool) query success\n     */\n    public function update_attribute($attribute, $value)\n    {\n        global $wpdb;\n\n        $this->{$attribute} = $value;\n\n        $sql = sprintf(\n            \"UPDATE %s SET %s = '%s' WHERE id = %s\",\n            static::table_name(),\n            esc_sql($attribute),\n            esc_sql($value),\n            (int) $this->id\n        );\n\n        wp_cache_delete(static::cache_key($this->id), 'podlove-model');\n\n        return $wpdb->query($sql);\n    }\n\n    public static function create($attributes = [])\n    {\n        $class = get_called_class();\n        $instance = new $class();\n\n        foreach ($attributes as $key => $value) {\n            $instance->{$key} = $value;\n        }\n\n        $instance->save();\n\n        return $instance;\n    }\n\n    /**\n     * Saves changes to database.\n     *\n     * @todo use wpdb::insert()\n     */\n    public function save()\n    {\n        global $wpdb;\n\n        if ($this->is_new()) {\n            $this->set_defaults();\n\n            $sql = 'INSERT INTO '\n                 .static::table_name()\n                 .' ( '\n                 .implode(',', self::property_names())\n                 .' ) '\n                 .'VALUES'\n                 .' ( '\n                 .implode(',', array_map([$this, 'property_name_to_sql_value'], self::property_names()))\n                 .' );';\n            $success = $wpdb->query($sql);\n            if ($success) {\n                $this->id = $wpdb->insert_id;\n            }\n        } else {\n            $sql = 'UPDATE '.static::table_name()\n                 .' SET '\n                 .implode(',', array_map([$this, 'property_name_to_sql_update_statement'], self::property_names()))\n                 .' WHERE id = '.(int) $this->id;\n            $success = $wpdb->query($sql);\n        }\n\n        wp_cache_delete(static::cache_key($this->id), 'podlove-model');\n\n        $this->is_new = false;\n\n        do_action('podlove_model_save', $this);\n        do_action('podlove_model_change', $this);\n\n        return $success;\n    }\n\n    /**\n     * Return default values for properties.\n     *\n     * Can be overridden by inheriting model classes.\n     *\n     * @return array\n     */\n    public function default_values()\n    {\n        return [];\n    }\n\n    public function delete()\n    {\n        global $wpdb;\n\n        wp_cache_delete(static::cache_key($this->id), 'podlove-model');\n\n        $sql = 'DELETE FROM '\n             .static::table_name()\n             .' WHERE id = '.(int) $this->id;\n\n        $rows_affected = $wpdb->query($sql);\n\n        do_action('podlove_model_delete', $this);\n        do_action('podlove_model_change', $this);\n\n        return $rows_affected !== false;\n    }\n\n    /**\n     * Create database table based on defined properties.\n     *\n     * Automatically includes an id column as auto incrementing primary key.\n     *\n     * @todo allow model changes\n     */\n    public static function build()\n    {\n        global $wpdb;\n\n        $property_sql = [];\n        foreach (self::properties() as $property) {\n            $property_sql[] = \"`{$property['name']}` {$property['type']}\";\n        }\n\n        $sql = 'CREATE TABLE IF NOT EXISTS '\n             .static::table_name()\n             .' ('\n             .implode(',', $property_sql)\n             .' ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;';\n\n        $wpdb->query($sql);\n\n        self::build_indices();\n    }\n\n    /**\n     * Convention based index generation.\n     *\n     * Creates default indices for all columns matching both:\n     * - equals \"id\" or contains \"_id\"\n     * - doesn't have an index yet\n     */\n    public static function build_indices()\n    {\n        global $wpdb;\n\n        $indices_sql = 'SHOW INDEX FROM `'.static::table_name().'`';\n        $indices = $wpdb->get_results($indices_sql);\n        $index_columns = array_map(function ($index) {\n            return $index->Column_name;\n        }, $indices);\n\n        foreach (self::properties() as $property) {\n            if ($property['index'] && !in_array($property['name'], $index_columns)) {\n                $length = isset($property['index_length']) ? '('.(int) $property['index_length'].')' : '';\n                $unique = isset($property['unique']) && $property['unique'] ? 'UNIQUE' : '';\n                $sql = 'ALTER TABLE `'.static::table_name().'` ADD '.$unique.' INDEX `'.$property['name'].'` ('.$property['name'].$length.')';\n                $wpdb->query($sql);\n            }\n        }\n    }\n\n    /**\n     * Retrieves the database table name.\n     *\n     * The name is derived from the namespace an class name. Additionally, it\n     * is prefixed with the global WordPress database table prefix.\n     *\n     * @return string database table name\n     */\n    public static function table_name()\n    {\n        global $wpdb;\n\n        return $wpdb->prefix.self::name();\n    }\n\n    public static function table_exists()\n    {\n        global $wpdb;\n        $sql = $wpdb->prepare('SHOW TABLES LIKE %s', \\Podlove\\esc_like(self::table_name()));\n\n        return $wpdb->get_var($sql) !== null;\n    }\n\n    /**\n     * Model identifier.\n     */\n    public static function name()\n    {\n        // get name of implementing class\n        $table_name = get_called_class();\n        // replace backslashes from namespace by underscores\n        $table_name = str_replace('\\\\', '_', $table_name);\n        // remove Models subnamespace from name\n        $table_name = str_replace('Model_', '', $table_name);\n\n        // all lowercase\n        return strtolower($table_name);\n    }\n\n    public static function destroy()\n    {\n        global $wpdb;\n        $wpdb->query('DROP TABLE IF EXISTS '.static::table_name());\n    }\n\n    public static function delete_all($reset_autoincrement = true)\n    {\n        global $wpdb;\n        $wpdb->query('TRUNCATE '.static::table_name());\n\n        if ($reset_autoincrement) {\n            $wpdb->query('ALTER TABLE '.static::table_name().' AUTO_INCREMENT = 1');\n        }\n    }\n\n    public static function find_one_by_sql($sql)\n    {\n        global $wpdb;\n\n        $class = get_called_class();\n        $model = new $class();\n        $model->flag_as_not_new();\n\n        $row = $wpdb->get_row($sql);\n\n        if (!$row) {\n            return null;\n        }\n\n        foreach ($row as $property => $value) {\n            $model->{$property} = $value;\n        }\n\n        return $model;\n    }\n\n    public static function find_all_by_sql($sql)\n    {\n        global $wpdb;\n\n        $class = get_called_class();\n        $models = [];\n\n        $rows = $wpdb->get_results($sql);\n\n        if (!$rows) {\n            return [];\n        }\n\n        foreach ($rows as $row) {\n            $model = new $class();\n            $model->flag_as_not_new();\n            foreach ($row as $property => $value) {\n                $model->{$property} = $value;\n            }\n            $models[] = $model;\n        }\n\n        return $models;\n    }\n\n    public function to_array()\n    {\n        return array_combine(\n            static::property_names(),\n            array_map(\n                function ($property) {\n                    return \\maybe_unserialize($this->{$property});\n                },\n                static::property_names()\n            )\n        );\n    }\n\n    private function set_property($name, $value)\n    {\n        $this->data[$name] = $value;\n    }\n\n    private function get_property($name)\n    {\n        if (isset($this->data[$name])) {\n            return $this->data[$name];\n        }\n\n        return null;\n    }\n\n    /**\n     * Return a list of property dictionaries.\n     *\n     * @return array property list\n     */\n    private static function properties()\n    {\n        $class = get_called_class();\n\n        if (!isset(self::$properties[$class])) {\n            self::$properties[$class] = [];\n        }\n\n        return self::$properties[$class];\n    }\n\n    /**\n     * Sets default values.\n     *\n     * @return array\n     */\n    private function set_defaults()\n    {\n        $defaults = $this->default_values();\n        $defaults = apply_filters('podlove_model_defaults', $defaults, $this);\n\n        if (!is_array($defaults) || empty($defaults)) {\n            return;\n        }\n\n        foreach ($defaults as $property => $value) {\n            if ($this->{$property} === null) {\n                $this->{$property} = $value;\n            }\n        }\n    }\n\n    private function property_name_to_sql_update_statement($p)\n    {\n        global $wpdb;\n\n        if ($this->{$p} !== null && $this->{$p} !== '') {\n            return sprintf(\"%s = '%s'\", $p, esc_sql(maybe_serialize($this->{$p})));\n        }\n\n        return \"{$p} = NULL\";\n    }\n\n    private function property_name_to_sql_value($p)\n    {\n        global $wpdb;\n\n        if ($this->{$p} !== null && $this->{$p} !== '') {\n            return sprintf(\"'%s'\", esc_sql(maybe_serialize($this->{$p})));\n        }\n\n        return 'NULL';\n    }\n}\n"
  },
  {
    "path": "lib/model/download_intent.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Raw download intent data.\n *\n * If you want to run analytics queries, you probably want to use\n * the DownloadIntentClean table.\n */\nclass DownloadIntent extends Base\n{\n    public function add_geo_data($ip_string)\n    {\n        $geoip_file = \\Podlove\\Geo_Ip::get_upload_file_path();\n\n        try {\n            $reader = new \\GeoIp2\\Database\\Reader($geoip_file);\n        } catch (\\Exception $e) {\n            return $this;\n        }\n\n        try {\n            // geo ip lookup\n            $record = $reader->city($ip_string);\n\n            $this->lat = $record->location->latitude;\n            $this->lng = $record->location->longitude;\n\n            /**\n             * Get most specific area for given record, beginning at the given area-type.\n             *\n             * Missing records will be created on the fly, based on data in $record.\n             *\n             * @param object $record GeoIp object\n             * @param string $type   Area identifier. One of: city, subdivision, country, continent.\n             */\n            $get_area = function ($record, $type) use (&$get_area) {\n                // get parent area for the given area-type\n                $get_parent_area = function ($type) use ($get_area, $record) {\n                    switch ($type) {\n                        case 'city':\n                            return $get_area($record, 'subdivision');\n\n                            break;\n                        case 'subdivision':\n                            return $get_area($record, 'country');\n\n                            break;\n                        case 'country':\n                            return $get_area($record, 'continent');\n\n                            break;\n                        case 'continent':\n                            // has no parent\n                            break;\n                    }\n\n                    return null;\n                };\n\n                $subRecord = $record->{$type == 'subdivision' ? 'mostSpecificSubdivision' : $type};\n\n                if (!$subRecord->geonameId) {\n                    return $get_parent_area($type);\n                }\n\n                if ($area = GeoArea::find_one_by_property('geoname_id', $subRecord->geonameId)) {\n                    return $area;\n                }\n\n                $area = new GeoArea();\n                $area->geoname_id = $subRecord->geonameId;\n                $area->type = $type;\n\n                if (isset($subRecord->code)) {\n                    $area->code = $subRecord->code;\n                } elseif (isset($subRecord->isoCode)) {\n                    $area->code = $subRecord->isoCode;\n                }\n\n                if ($area->type != 'continent') {\n                    if ($parent_area = $get_parent_area($area->type)) {\n                        $area->parent_id = $parent_area->id;\n                    }\n                }\n\n                $area->save();\n\n                // save name and translations\n                foreach ($subRecord->names as $lang => $name) {\n                    $n = new GeoAreaName();\n                    $n->area_id = $area->id;\n                    $n->language = $lang;\n                    $n->name = $name;\n                    $n->save();\n                }\n\n                return $area;\n            };\n\n            $area = $get_area($record, 'city');\n\n            $this->geo_area_id = $area->id;\n        } catch (\\Exception $e) {\n            // geo lookup might fail, but that's not grave\n        }\n\n        return $this;\n    }\n}\n\nDownloadIntent::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nDownloadIntent::property('user_agent_id', 'INT');\nDownloadIntent::property('media_file_id', 'INT');\nDownloadIntent::property('request_id', 'VARCHAR(32)');\nDownloadIntent::property('accessed_at', 'DATETIME');\nDownloadIntent::property('source', 'VARCHAR(255)');\nDownloadIntent::property('context', 'VARCHAR(255)');\nDownloadIntent::property('geo_area_id', 'INT');\nDownloadIntent::property('lat', 'FLOAT');\nDownloadIntent::property('lng', 'FLOAT');\nDownloadIntent::property('httprange', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/model/download_intent_clean.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Contains cleaned up data of DownloadIntent table.\n */\nclass DownloadIntentClean extends Base\n{\n    public static function build()\n    {\n        global $wpdb;\n\n        parent::build();\n\n        // Guard index creation to avoid noisy \"Duplicate key name\" errors\n        // when setup/build runs multiple times (e.g. activation + migrations).\n        $table = \\Podlove\\Model\\DownloadIntentClean::table_name();\n        $index_exists = (bool) $wpdb->get_var(\n            $wpdb->prepare(\n                \"SHOW INDEX FROM `{$table}` WHERE Key_name = %s\",\n                'accessed_at'\n            )\n        );\n\n        if (!$index_exists) {\n            $sql = 'CREATE INDEX accessed_at ON `%s` (accessed_at)';\n            $wpdb->query(sprintf($sql, $table));\n        }\n    }\n\n    public static function episode_age_in_hours($episode_id)\n    {\n        global $wpdb;\n\n        // This query is a bit slow, ~50ms on 2MM intents table.\n        // It might be acceptable if not used in a loop.\n        // If the actual episode age is acceptable (rather than age in intents),\n        // use the quicker alternative: `actual_episode_age_in_hours`\n        return $wpdb->get_var(\n            $wpdb->prepare(\n                'SELECT MAX(hours_since_release)\n\t\t\t\tFROM '.self::table_name().' di\n\t\t\t\tJOIN '.MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\t\tWHERE mf.episode_id = %d',\n                $episode_id\n            )\n        );\n    }\n\n    public static function actual_episode_age_in_hours($episode_id)\n    {\n        global $wpdb;\n\n        return $wpdb->get_var(\n            $wpdb->prepare(\n                'SELECT TIMESTAMPDIFF(HOUR, p.post_date, NOW())\n\t\t\t\tFROM `'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.'` p ON p.ID = e.`post_id`\n\t\t\t\tWHERE e.id = %d',\n                $episode_id\n            )\n        );\n    }\n\n    public static function top_episode_ids($start, $end = 'now', $limit = 3)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tepisode_id, COUNT(*) downloads\n\t\t\tFROM\n\t\t\t\t'.self::table_name().' di\n\t\t\t\tJOIN '.MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\t\tJOIN '.Episode::table_name().' e ON e.id = mf.episode_id\n\t\t\tWHERE\n\t\t\t\t'.self::sql_condition_from_time_strings($start, $end).'\n\t\t\tGROUP BY\n\t\t\t\tepisode_id\n\t\t\tORDER BY\n\t\t\t\tdownloads DESC\n\t\t\tLIMIT\n\t\t\t\t0, %d\n\t\t';\n\n        return $wpdb->get_col(\n            $wpdb->prepare($sql, $limit)\n        );\n    }\n\n    /**\n     * For an episode, get the day with the most downloads and the number of downloads.\n     *\n     * @param int $episode_id\n     *\n     * @return array with keys \"downloads\" and \"theday\"\n     */\n    public static function peak_download_by_episode_id($episode_id)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tCOUNT(*) downloads, DATE(accessed_at) theday\n\t\t\tFROM\n\t\t\t\t'.self::table_name().' di\n\t\t\t\tINNER JOIN '.MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\tWHERE\n\t\t\t\tepisode_id = %d\n\t\t\tGROUP BY theday\n\t\t\tORDER BY downloads DESC\n\t\t\tLIMIT 0,1\n\t\t';\n\n        return $wpdb->get_row(\n            $wpdb->prepare($sql, $episode_id),\n            ARRAY_A\n        );\n    }\n\n    public static function total_by_episode_id($episode_id, $start = null, $end = null)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tCOUNT(*)\n\t\t\tFROM\n\t\t\t\t'.self::table_name().' di\n\t\t\t\tINNER JOIN '.MediaFile::table_name().' mf ON mf.id = di.media_file_id\n\t\t\tWHERE\n\t\t\t\tepisode_id = %d\n\t\t\t\tAND '.self::sql_condition_from_time_strings($start, $end).'\n\t\t';\n\n        return $wpdb->get_var(\n            $wpdb->prepare($sql, $episode_id)\n        );\n    }\n\n    public static function prev_month_downloads()\n    {\n        global $wpdb;\n\n        $cur_month = date('m');\n        $last_month = $cur_month - 1;\n        $year = date('Y');\n\n        if ($last_month < 1) {\n            $last_month = 12;\n            --$year;\n        }\n\n        if ($last_month < 10) {\n            $last_month = \"0{$last_month}\";\n        }\n\n        $last_month_time = strtotime(\"{$year}-{$last_month}\");\n        $last_month_name = date('F Y', $last_month_time);\n\n        $where_start = (new \\DateTime(\"{$year}-{$last_month}\"))->format('Y-m-d H:i:s');\n        $where_end = (new \\DateTime(\"last day of {$year}-{$last_month}\"))->modify('+ 1 day - 1 second')->format('Y-m-d H:i:s');\n\n        $sql = 'SELECT COUNT(*) FROM '.self::table_name().' d WHERE accessed_at >= \"'.$where_start.'\" AND accessed_at <= \"'.$where_end.'\"';\n\n        return [\n            'downloads' => $wpdb->get_var($sql),\n            'time' => $last_month_time,\n            'homan_readable_month' => $last_month_name,\n        ];\n    }\n\n    public static function last_7days_downloads()\n    {\n        global $wpdb;\n\n        $sql = 'SELECT COUNT(*) FROM '.self::table_name().' d WHERE accessed_at > DATE_SUB(NOW(), INTERVAL 7 DAY)';\n\n        return $wpdb->get_var($sql);\n    }\n\n    public static function last_24hours_downloads()\n    {\n        global $wpdb;\n\n        $sql = 'SELECT COUNT(*) FROM '.self::table_name().' d WHERE accessed_at > DATE_SUB(NOW(), INTERVAL 1 DAY)';\n\n        return $wpdb->get_var($sql);\n    }\n\n    public static function total_downloads()\n    {\n        global $wpdb;\n\n        $sql\n       = 'SELECT SUM(meta_value) total\n          FROM `'.$wpdb->postmeta.'`\n         WHERE `meta_key` = \"_podlove_downloads_total\"\n           AND post_id IN (select id from `'.$wpdb->posts.'` where post_type = \"podcast\" and post_status IN (\\'private\\', \\'draft\\', \\'publish\\', \\'pending\\', \\'future\\'))\n        ';\n\n        return $wpdb->get_var($sql);\n    }\n\n    public static function total_downloads_by_show($where)\n    {\n        global $wpdb;\n\n        $sql\n        = 'SELECT\n             count(di.id) as downloads,\n             tr.term_taxonomy_id AS show_id,\n             t. `name` AS show_name\n         FROM\n             `'.self::table_name().'` di\n             JOIN `'.MediaFile::table_name().'` mf ON mf.id = di.media_file_id\n             JOIN `'.Episode::table_name().'` e ON e.id = mf.episode_id\n             LEFT JOIN `'.$wpdb->term_relationships.'` tr ON tr.object_id = e.post_id\n             LEFT JOIN `'.$wpdb->terms.'` t ON t.term_id = tr.term_taxonomy_id\n             LEFT JOIN `'.$wpdb->term_taxonomy.'` tt ON tt.term_taxonomy_id = tr.term_taxonomy_id\n         WHERE '.$where.' AND tt.taxonomy = \\'shows\\'\n         GROUP BY\n             tr.term_taxonomy_id\n         ORDER BY\n             downloads DESC\n         ';\n\n        return $wpdb->get_results($sql, ARRAY_A);\n    }\n\n    /**\n     * Generate WHERE clause to a certain time range or day.\n     *\n     * If $start and $end are given, they describe a time range.\n     * If only $start is given, only data from this day will be returned.\n     * If none are given, there is no time restriction. \"1 = 1\" will be returned instead.\n     *\n     * @param string $start      Timerange start in words, or null. Default: null.\n     * @param string $end        Timerange end in words, or null. Default: null.\n     * @param string $tableAlias DownloadIntent table alias. Default: \"di\".\n     *\n     * @return string\n     */\n    private static function sql_condition_from_time_strings($start = null, $end = null, $tableAlias = 'di')\n    {\n        $strToMysqlDateTime = function ($s) {\n            return date('Y-m-d H:i:s', strtotime($s));\n        };\n        $strToMysqlDate = function ($s) {\n            return date('Y-m-d', strtotime($s));\n        };\n        $startOfDay = function ($s) {\n            return date('Y-m-d H:i:s', strtotime('midnight', strtotime($s)));\n        };\n        $endOfDay = function ($s) use ($startOfDay) {\n            return date('Y-m-d H:i:s', strtotime('tomorrow', strtotime($startOfDay($s))) - 1);\n        };\n\n        if ($start && $end) {\n            $timerange = \"{$tableAlias}.accessed_at BETWEEN '{$strToMysqlDateTime($startOfDay($start))}' AND '{$strToMysqlDateTime($endOfDay($end))}'\";\n        } elseif ($start) {\n            $timerange = \"DATE({$tableAlias}.accessed_at) = '{$strToMysqlDate($start)}'\";\n        } else {\n            $timerange = '1 = 1';\n        }\n\n        return $timerange;\n    }\n}\n\nDownloadIntentClean::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nDownloadIntentClean::property('user_agent_id', 'INT');\nDownloadIntentClean::property('media_file_id', 'INT');\nDownloadIntentClean::property('request_id', 'VARCHAR(32)');\nDownloadIntentClean::property('accessed_at', 'DATETIME');\nDownloadIntentClean::property('source', 'VARCHAR(255)');\nDownloadIntentClean::property('context', 'VARCHAR(255)');\nDownloadIntentClean::property('geo_area_id', 'INT');\nDownloadIntentClean::property('lat', 'FLOAT');\nDownloadIntentClean::property('lng', 'FLOAT');\nDownloadIntentClean::property('httprange', 'VARCHAR(255)');\nDownloadIntentClean::property('hours_since_release', 'INT');\n"
  },
  {
    "path": "lib/model/episode.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nuse Podlove\\ChaptersManager;\nuse Podlove\\Log;\n\n/**\n * We could use simple post_meta instead of a table here.\n */\nclass Episode extends Base implements Licensable\n{\n    use KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function find_all_by_time($args = [])\n    {\n        global $wpdb;\n\n        $defaults = [\n            'post_status' => ['private', 'draft', 'publish', 'pending', 'future'],\n            'sort_by' => 'post_date',\n            'order_by' => 'DESC',\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        if (!is_array($args['post_status'])) {\n            $args['post_status'] = [$args['post_status']];\n        }\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\te.*\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.'` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_status IN ('.implode(', ', array_map(function ($s) {\n            return '\"'.$s.'\"';\n        }, $args['post_status'])).')\n\t\t\t\tAND\n\t\t\t\tp.post_type = \"podcast\"\n\t\t\tORDER BY\n\t\t\t    p.'.$args['sort_by'].' '.$args['order_by'];\n\n        return Episode::find_all_by_sql($sql);\n    }\n\n    /**\n     * Return number of rows in the table.\n     *\n     * @return int number of rows\n     */\n    public static function count_published()\n    {\n        global $wpdb;\n\n        $sql = '\n        SELECT\n            COUNT(*)\n        FROM\n            `'.Episode::table_name().'` e\n            JOIN `'.$wpdb->posts.'` p ON e.post_id = p.ID\n        WHERE\n            p.post_status IN (\"publish\")\n            AND\n            p.post_type = \"podcast\"';\n\n        return (int) $wpdb->get_var($sql);\n    }\n\n    public static function latest()\n    {\n        global $wpdb;\n\n        // Why do we fetch 15 instead of just 1?\n        // Because some of the newest ones might be drafts or otherwise invalid.\n        // So we grab a bunch, filter by validity and then return the first one.\n        $sql = '\n\t\t\tSELECT\n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.'` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_type = \"podcast\"\n\t\t\tORDER BY\n\t\t\t\tp.post_date DESC\n\t\t\tLIMIT 0, 15';\n\n        $episodes = array_filter(Episode::find_all_by_sql($sql), function ($e) {\n            return $e->is_valid();\n        });\n\n        return reset($episodes);\n    }\n\n    public function days_since_release()\n    {\n        $releaseDate = new \\DateTime($this->post()->post_date);\n        $releaseDate->setTime(0, 0, 0);\n\n        $diff = $releaseDate->diff(new \\DateTime());\n\n        return $diff->days;\n    }\n\n    public function hours_since_release()\n    {\n        $release = strtotime($this->post()->post_date_gmt);\n        $now = time();\n\n        return floor(($now - $release) / 3600);\n    }\n\n    public function title()\n    {\n        return $this->title_with_fallback();\n    }\n\n    /**\n     * Returns episode title if set, otherwise post title.\n     *\n     * @return string\n     */\n    public function title_with_fallback()\n    {\n        if ($this->title) {\n            return $this->title;\n        }\n\n        return $this->post_title();\n    }\n\n    public function post_title()\n    {\n        return $this->with_blog_scope(function () {\n            return get_the_title($this->post_id);\n        });\n    }\n\n    /**\n     * Generate a human readable title.\n     *\n     * Return name and, if available, the subtitle. Separated by a dash.\n     *\n     * @return string\n     */\n    public function full_title()\n    {\n        $title = $this->title();\n\n        if ($this->subtitle) {\n            $title = $title.' - '.$this->subtitle;\n        }\n\n        return $title;\n    }\n\n    public function number_padded()\n    {\n        return str_pad(\n            (string) $this->number,\n            \\Podlove\\get_setting('website', 'episode_number_padding'),\n            '0',\n            STR_PAD_LEFT\n        );\n    }\n\n    public function description()\n    {\n        if ($this->summary) {\n            $description = $this->summary;\n        } elseif ($this->subtitle) {\n            $description = $this->subtitle;\n        } else {\n            $description = $this->title();\n        }\n\n        return htmlspecialchars(trim($description));\n    }\n\n    /**\n     * Episode slug.\n     *\n     * Including output escaping. Use when displaying the slug anywhere in the\n     * UI. As part of a file URL, use \\Podlove\\prepare_episode_slug_for_url\n     * instead.\n     */\n    public function slug(): string\n    {\n        return htmlspecialchars($this->slug);\n    }\n\n    public function post()\n    {\n        return $this->with_blog_scope(function () {\n            return get_post($this->post_id);\n        });\n    }\n\n    public function permalink()\n    {\n        return $this->with_blog_scope(function () {\n            return get_permalink($this->post_id);\n        });\n    }\n\n    public function meta($meta_key, $single = true)\n    {\n        return $this->with_blog_scope(function () use ($meta_key, $single) {\n            return get_post_meta($this->post_id, $meta_key, $single);\n        });\n    }\n\n    public function tags($args = [])\n    {\n        return $this->with_blog_scope(function () use ($args) {\n            return wp_get_post_tags($this->post_id, $args);\n        });\n    }\n\n    public function categories($args = [])\n    {\n        // \"wp_get_post_categories\" defaults to \"fields => ids\" so we need to set it manually\n        $args['fields'] = 'all';\n\n        return $this->with_blog_scope(function () use ($args) {\n            return wp_get_post_categories($this->post_id, $args);\n        });\n    }\n\n    public function explicit_text()\n    {\n        // backwards compatibility\n        if ($this->explicit == 2) {\n            return 'false';\n        }\n\n        return $this->explicit ? 'true' : 'false';\n    }\n\n    public function media_files($args = [])\n    {\n        return $this->with_blog_scope(function () use ($args) {\n            $assetNameWhere = '';\n            if (isset($args['identifier'])) {\n                $assetNameWhere = 'AND A.identifier = \"'.esc_sql($args['identifier']).'\"';\n            }\n\n            $sql = '\n\t\t\t\tSELECT M.*\n\t\t\t\tFROM '.MediaFile::table_name().' M\n\t\t\t\t\tJOIN '.EpisodeAsset::table_name().' A ON A.id = M.episode_asset_id\n\t\t\t\tWHERE M.episode_id = \\''.$this->id.'\\' '.$assetNameWhere.'\n\t\t\t\tORDER BY A.position ASC\n\t\t\t';\n\n            return MediaFile::find_all_by_sql($sql);\n        });\n    }\n\n    public static function find_or_create_by_post_id($post_id)\n    {\n        $episode = Episode::find_one_by_property('post_id', $post_id);\n\n        if ($episode) {\n            return $episode;\n        }\n\n        $episode = new Episode();\n        $episode->post_id = $post_id;\n        $episode->save();\n\n        return $episode;\n    }\n\n    public function enclosure_url($episode_asset, $source = 'feed', $context = null)\n    {\n        return MediaFile::find_by_episode_id_and_episode_asset_id($this->id, $episode_asset->id)->get_public_file_url($source, $context);\n    }\n\n    public function cover_art_with_fallback()\n    {\n        return $this->with_blog_scope(function () {\n            if (!$image = $this->cover_art()) {\n                $image = Podcast::get()->cover_art();\n            }\n\n            return $image;\n        });\n    }\n\n    public function cover_art()\n    {\n        return $this->with_blog_scope(function () {\n            $asset_assignment = AssetAssignment::get_instance();\n\n            if (!$asset_assignment->image) {\n                return false;\n            }\n\n            if ($asset_assignment->image == 'post-thumbnail') {\n                if (has_post_thumbnail($this->post_id)) {\n                    $size = apply_filters('podlove-post-thumbnail-cover-size', [3000, 3000]);\n\n                    return new Image(get_the_post_thumbnail_url($this->post_id, $size), $this->title());\n                }\n\n                return false;\n            }\n\n            if ($asset_assignment->image == 'manual') {\n                if ($this->cover_art == null) {\n                    return false;\n                }\n\n                $cover_art = trim($this->cover_art);\n\n                if (empty($cover_art)) {\n                    return false;\n                }\n\n                return new Image($cover_art, $this->title());\n            }\n\n            $cover_art_file_id = $asset_assignment->image;\n            if (!$asset = EpisodeAsset::find_one_by_id($cover_art_file_id)) {\n                return false;\n            }\n\n            if (!$file = MediaFile::find_by_episode_id_and_episode_asset_id($this->id, $asset->id)) {\n                return false;\n            }\n\n            return ($file->size > 0) ? new Image($file->get_file_url(), $this->title()) : false;\n        });\n    }\n\n    /**\n     * Get episode chapters.\n     *\n     * @param string $format object, psc, mp4chaps, json, pijson. Default: object\n     *\n     * @return mixed\n     */\n    public function get_chapters($format = 'object')\n    {\n        return $this->with_blog_scope(function () use ($format) {\n            return (new ChaptersManager($this))->get($format);\n        });\n    }\n\n    public function refetch_files()\n    {\n        $valid_files = [];\n        foreach (EpisodeAsset::all() as $asset) {\n            if ($file = MediaFile::find_by_episode_id_and_episode_asset_id($this->id, $asset->id)) {\n                $file->determine_file_size();\n                $file->save();\n\n                if ($file->is_valid()) {\n                    $valid_files[] = $file->id;\n                }\n            }\n        }\n\n        if (empty($valid_files) && get_post_status($this->post_id) == 'publish') {\n            Log::get()->addAlert('All assets for this episode are invalid!', ['episode_id' => $this->id]);\n        }\n    }\n\n    public function get_duration($format = 'HH:MM:SS')\n    {\n        return (new \\Podlove\\Duration($this->duration))->get($format);\n    }\n\n    /**\n     * @todo episode should not know about cache; better: $cache->delete_for($episode)\n     */\n    public function delete_caches()\n    {\n        // delete caches for current episode\n        delete_transient('podlove_chapters_string_'.$this->id);\n\n        // delete caches for revisions of this episode\n        if ($revisions = wp_get_post_revisions($this->post_id)) {\n            foreach ($revisions as $revision) {\n                if ($revision_episode = Episode::find_one_by_post_id($revision->ID)) {\n                    delete_transient('podlove_chapters_string_'.$revision_episode->id);\n                }\n            }\n        }\n\n        \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n    }\n\n    /**\n     * Check for basic validity.\n     *\n     * - MUST have an existing associated post\n     * - associated post MUST be of type 'podcast'\n     * - MUST NOT be deleted/trashed\n     *\n     * @return bool\n     */\n    public function is_valid()\n    {\n        $post = get_post($this->post_id);\n\n        if (!$post) {\n            return false;\n        }\n\n        // skip deleted podcasts\n        if (!in_array($post->post_status, ['private', 'draft', 'publish', 'pending', 'future'])) {\n            return false;\n        }\n\n        // skip versions\n        if ($post->post_type != 'podcast') {\n            return false;\n        }\n\n        return true;\n    }\n\n    public function is_published()\n    {\n        if (!$post = get_post($this->post_id)) {\n            return false;\n        }\n\n        return in_array($post->post_status, ['private', 'publish']);\n    }\n\n    public function get_license()\n    {\n        return new License('episode', [\n            'license_name' => $this->license_name,\n            'license_url' => $this->license_url,\n        ]);\n    }\n\n    public function get_license_picture_url()\n    {\n        return $this->get_license()->getPictureUrl();\n    }\n\n    public function get_license_html()\n    {\n        return $this->get_license()->getHtml();\n    }\n\n    public function get_soundbite_start($format = 'HH:MM:SS')\n    {\n        return (new \\Podlove\\Duration($this->soundbite_start))->get($format);\n    }\n\n    public function get_soundbite_duration($format = 'HH:MM:SS')\n    {\n        return (new \\Podlove\\Duration($this->soundbite_duration))->get($format);\n    }\n\n    public static function get_next_episode_number($show_slug = null)\n    {\n        global $wpdb;\n\n        $sql_fragment = '';\n        if ($show_slug) {\n            $show_term = get_term_by('slug', $show_slug, 'shows');\n            $sql_fragment = ' AND p.id in (select object_id from `'.$wpdb->term_relationships.'` where term_taxonomy_id = '.(int) $show_term->term_taxonomy_id.') ';\n        }\n\n        $sql = 'SELECT\n            MAX(e.number) + 1 AS new_number\n        FROM\n        `'.Episode::table_name().'` e\n            JOIN `'.$wpdb->posts.'` p ON e.post_id = p.id\n        WHERE\n            p.post_type = \\'podcast\\' AND\n            p.post_status IN (\\'draft\\', \\'publish\\', \\'private\\', \\'pending\\', \\'future\\')\n            '.$sql_fragment.'\n        ';\n\n        $number = (int) $wpdb->get_var($sql);\n\n        if ($number > 0) {\n            return $number;\n        }\n\n        return 1;\n    }\n\n    /**\n     * Check if slug is frozen.\n     *\n     * @return bool\n     */\n    public function is_slug_frozen()\n    {\n        return (bool) $this->slug_frozen;\n    }\n\n    /**\n     * Freeze the slug to prevent automatic changes.\n     */\n    public function freeze_slug()\n    {\n        if (!$this->is_slug_frozen()) {\n            $this->slug_frozen = 1;\n            $this->save();\n        }\n    }\n\n    /**\n     * Unfreeze the slug to allow automatic changes.\n     */\n    public function unfreeze_slug()\n    {\n        if ($this->is_slug_frozen()) {\n            $this->slug_frozen = 0;\n            $this->save();\n        }\n    }\n}\n\nEpisode::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nEpisode::property('post_id', 'INT');\nEpisode::property('title', 'TEXT');\nEpisode::property('subtitle', 'TEXT');\nEpisode::property('summary', 'TEXT');\nEpisode::property('number', 'INT UNSIGNED');\nEpisode::property('type', 'VARCHAR(10)');\nEpisode::property('enable', 'INT'); // listed in podcast directories or not?\nEpisode::property('slug', 'VARCHAR(255)');\nEpisode::property('duration', 'VARCHAR(255)');\nEpisode::property('cover_art', 'VARCHAR(255)');\nEpisode::property('chapters', 'TEXT');\nEpisode::property('recording_date', 'DATETIME');\nEpisode::property('explicit', 'TINYINT');\nEpisode::property('license_name', 'TEXT');\nEpisode::property('license_url', 'TEXT');\nEpisode::property('soundbite_start', 'VARCHAR(255)');\nEpisode::property('soundbite_duration', 'VARCHAR(255)');\nEpisode::property('soundbite_title', 'VARCHAR(255)');\nEpisode::property('slug_frozen', 'TINYINT DEFAULT 0');\n"
  },
  {
    "path": "lib/model/episode_asset.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass EpisodeAsset extends Base\n{\n    use KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function save()\n    {\n        global $wpdb;\n\n        if (!$this->position) {\n            $pos = $wpdb->get_var(sprintf('SELECT MAX(position)+1 FROM %s', self::table_name()));\n            $this->position = $pos ? $pos : 1;\n        }\n\n        parent::save();\n\n        $this->maybe_connect_to_web_player();\n    }\n\n    /**\n     * Find the related media format model.\n     *\n     * @return null|\\Podlove\\Model\\FileType\n     */\n    public function file_type()\n    {\n        return $this->with_blog_scope(function () {\n            return FileType::find_by_id($this->file_type_id);\n        });\n    }\n\n    /**\n     * Find all media file models in this location.\n     *\n     * @return null|array\n     */\n    public function media_files()\n    {\n        return $this->with_blog_scope(function () {\n            return MediaFile::find_all_by_episode_asset_id($this->id);\n        });\n    }\n\n    /**\n     * Find all media files with a size > 0.\n     *\n     * @todo performance (1+n)\n     *\n     * @return null|array\n     */\n    public function active_media_files()\n    {\n        return array_filter($this->media_files(), function ($file) {\n            if ($file->size <= 0) {\n                return false;\n            }\n\n            return in_array(get_post($file->episode()->post_id)->post_status, ['publish', 'private', 'draft', 'future']);\n        });\n    }\n\n    public function title()\n    {\n        if ($this->file_type_id) {\n            return $this->file_type()->title();\n        }\n\n        return __('Notice: No file format defined.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    /**\n     * Checks if asset is used by web player.\n     *\n     * @return bool true if connected to any web player asset, otherwise false\n     */\n    public function is_connected_to_web_player()\n    {\n        foreach (get_option('podlove_webplayer_formats', []) as $_ => $media_types) {\n            foreach ($media_types as $asset_id) {\n                if ($asset_id == $this->id) {\n                    return true;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Use for web player if this web player slot is not yet taken.\n     */\n    public function maybe_connect_to_web_player()\n    {\n        $webplayer_formats = get_option('podlove_webplayer_formats', []);\n        $allowed_formats = \\Podlove\\Settings\\Expert\\Tab\\WebPlayer::formats();\n        $asset_type = $this->file_type()->mime_type;\n        $type = substr($asset_type, 0, stripos($asset_type, '/'));\n\n        if (isset($allowed_formats[$type])) {\n            foreach ($allowed_formats[$type] as $extension => $format_data) {\n                if (in_array($asset_type, $format_data['mime_types'])) {\n                    if (!isset($webplayer_formats[$type])) {\n                        $webplayer_formats[$type] = [];\n                    }\n\n                    if (!isset($webplayer_formats[$type][$extension]) || !$webplayer_formats[$type][$extension]) {\n                        $webplayer_formats[$type][$extension] = $this->id;\n                        update_option('podlove_webplayer_formats', $webplayer_formats);\n                    }\n\n                    break;\n                }\n            }\n        }\n    }\n\n    /**\n     * Checks if asset is connected to any feed.\n     *\n     * @return bool true if connected to any feed, otherwise false\n     */\n    public function is_connected_to_feed()\n    {\n        return (bool) Feed::find_one_by_episode_asset_id($this->id);\n    }\n\n    /**\n     * Checks if asset has an active media file.\n     *\n     * A media file is active if its file size is > 0.\n     *\n     * @return bool true if any media file has a size > 0, otherwise false\n     */\n    public function has_active_media_files()\n    {\n        return count($this->active_media_files()) > 0;\n    }\n\n    /**\n     * Checks if asset is assigned as image or chapter asset.\n     *\n     * @return bool true if assigned, otherwise false\n     */\n    public function has_asset_assignments()\n    {\n        $assignment = AssetAssignment::get_instance();\n\n        return in_array($this->id, [$assignment->image, $assignment->chapters]);\n    }\n\n    /**\n     * Checks if asset should be deleted.\n     *\n     * Can only be deleted if all of the following applies to the asset:\n     * - has no active media file\n     * - has no asset assignment\n     * - is not connected to any feed\n     * - is not connected to web player\n     *\n     * @return bool true if it should be deleted, otherwise false\n     */\n    public function is_deletable()\n    {\n        return !$this->has_active_media_files()\n            && !$this->has_asset_assignments()\n            && !$this->is_connected_to_feed()\n            && !$this->is_connected_to_web_player();\n    }\n\n    /**\n     * @override \\Podlove\\Model\\Base::delete();\n     */\n    public function delete()\n    {\n        foreach ($this->media_files() as $media_file) {\n            $media_file->delete();\n        }\n        parent::delete();\n    }\n}\n\nEpisodeAsset::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nEpisodeAsset::property('title', 'VARCHAR(255)');\nEpisodeAsset::property('identifier', 'VARCHAR(255)');\nEpisodeAsset::property('file_type_id', 'INT');\nEpisodeAsset::property('suffix', 'VARCHAR(255)');\nEpisodeAsset::property('downloadable', 'INT');\nEpisodeAsset::property('position', 'FLOAT');\n"
  },
  {
    "path": "lib/model/feed.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nuse Podlove\\Modules\\Plus\\FeedProxy;\n\nclass Feed extends Base\n{\n    use KeepsBlogReferenceTrait;\n\n    public const ITEMS_WP_LIMIT = 0;\n    public const ITEMS_NO_LIMIT = -1;\n    public const ITEMS_GLOBAL_LIMIT = -2;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function save()\n    {\n        global $wpdb;\n\n        set_transient('podlove_needs_to_flush_rewrite_rules', true);\n        $this->slug = sanitize_title($this->slug);\n\n        if (!$this->position) {\n            $pos = $wpdb->get_var(sprintf('SELECT MAX(position)+1 FROM %s', self::table_name()));\n            $this->position = $pos ? $pos : 1;\n        }\n\n        parent::save();\n    }\n\n    /**\n     * Build public url where the feed can be subscribed at.\n     *\n     * @param null|mixed $taxonomy\n     * @param null|mixed $term_id\n     *\n     * @return string\n     */\n    public function get_subscribe_url($taxonomy = null, $term_id = null)\n    {\n        return $this->with_blog_scope(function () use ($taxonomy, $term_id) {\n            if ($taxonomy && $term_id) {\n                $url = get_term_feed_link($term_id, $taxonomy, $this->slug);\n            } else {\n                $url = get_feed_link($this->slug);\n            }\n\n            return apply_filters('podlove_subscribe_url', $url);\n        });\n    }\n\n    /**\n     * Get subscribe URL for feed or show feed based on query vars.\n     *\n     * @return string\n     */\n    public function get_contextual_subscribe_url()\n    {\n        if ($show_tax_slug = get_query_var('shows')) {\n            $show = get_term_by('slug', $show_tax_slug, 'shows');\n            $taxonomy = 'shows';\n            $term_id = $show->term_id;\n        } else {\n            $taxonomy = null;\n            $term_id = null;\n        }\n\n        return $this->get_subscribe_url($taxonomy, $term_id);\n    }\n\n    /**\n     * Build html link to subscribe.\n     *\n     * @return string\n     */\n    public function get_subscribe_link()\n    {\n        $url = $this->get_subscribe_url();\n\n        return sprintf('<a href=\"%s\">%s</a>', $url, $url);\n    }\n\n    public function get_redirect_url()\n    {\n        if (FeedProxy::is_enabled()) {\n            return FeedProxy::get_proxy_url($this->get_contextual_subscribe_url());\n        }\n\n        return $this->redirect_url;\n    }\n\n    public function get_redirect_http_status_code()\n    {\n        // most HTTP/1.0 client's don't understand 307, so we fall back to 302\n        if (FeedProxy::is_enabled()) {\n            return $_SERVER['SERVER_PROTOCOL'] == 'HTTP/1.0' ? 302 : 307;\n        }\n\n        return $_SERVER['SERVER_PROTOCOL'] == 'HTTP/1.0' && $this->redirect_http_status == 307 ? 302 : $this->redirect_http_status;\n    }\n\n    public function is_redirect_enabled()\n    {\n        return FeedProxy::is_enabled() || (strlen($this->redirect_url) > 0 && $this->get_redirect_http_status_code() > 0);\n    }\n\n    /**\n     * Build the title of the feed.\n     */\n    public function get_title()\n    {\n        $podcast = Podcast::get();\n\n        if ($this->append_name_to_podcast_title) {\n            return $podcast->title.' ('.$this->name.')';\n        }\n\n        return $podcast->title;\n    }\n\n    /**\n     * Get title for browser feed discovery.\n     *\n     * This title is used by clients to show the user the subscribe option he\n     * has. Therefore, the most obvious thing to do is to display the show\n     * title and the file extension in paranthesis.\n     *\n     * Fallback to internal feed name.\n     *\n     * @return string\n     */\n    public function title_for_discovery()\n    {\n        return $this->with_blog_scope(function () {\n            $podcast = Podcast::get();\n\n            if (!$episode_asset = $this->episode_asset()) {\n                return $this->name;\n            }\n\n            if (!$file_type = $episode_asset->file_type()) {\n                return $this->name;\n            }\n\n            $file_extension = $file_type->extension;\n\n            $title_template = is_feed() ? '%s (%s)' : __('Podcast Feed: %s (%s)', 'podcast');\n\n            $title = sprintf($title_template, $podcast->title, $this->name);\n\n            return apply_filters('podlove_feed_title_for_discovery', $title, $this->title, $file_extension, $this->id);\n        });\n    }\n\n    /**\n     * Find the related episode asset model.\n     *\n     * @return null|\\Podlove\\Model\\EpisodeAsset\n     */\n    public function episode_asset()\n    {\n        return $this->with_blog_scope(function () {\n            return $this->episode_asset_id ? EpisodeAsset::find_by_id($this->episode_asset_id) : null;\n        });\n    }\n\n    /**\n     * Find all post_ids associated with this feed.\n     *\n     * @return array\n     */\n    public function post_ids()\n    {\n        global $wpdb;\n\n        $allowed_status = ['publish'];\n        $allowed_status = apply_filters('podlove_feed_post_ids_allowed_status', $allowed_status);\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tp.ID\n\t\t\tFROM\n\t\t\t\t'.$wpdb->posts.' p\n\t\t\t\tINNER JOIN '.Episode::table_name().' e ON e.post_id = p.ID\n\t\t\t\tINNER JOIN '.MediaFile::table_name().' mf ON mf.`episode_id` = e.id AND mf.active = 1\n\t\t\t\tINNER JOIN '.EpisodeAsset::table_name().' a ON a.id = mf.`episode_asset_id`\n\t\t\tWHERE\n\t\t\t\ta.id = %d\n\t\t\t\tAND\n\t\t\t\tp.post_status IN ('.implode(',', array_map(function ($s) {\n            return \"'{$s}'\";\n        }, $allowed_status)).')\n\t\t\tORDER BY\n\t\t\t\tp.post_date DESC\n\t\t';\n\n        return $wpdb->get_col(\n            $wpdb->prepare(\n                $sql,\n                $this->episode_asset()->id\n            )\n        );\n    }\n\n    public function get_content_type()\n    {\n        return 'application/rss+xml';\n    }\n\n    public function get_feed_self_link()\n    {\n        $href = $this->get_contextual_subscribe_url();\n\n        $current_page = (get_query_var('paged')) ? get_query_var('paged') : 1;\n        if ($current_page > 1) {\n            $href .= '?paged='.$current_page;\n        }\n\n        return self::get_link_tag([\n            'prefix' => 'atom',\n            'rel' => 'self',\n            'type' => $this->get_content_type(),\n            'title' => \\Podlove\\Feeds\\prepare_for_feed($this->title_for_discovery()),\n            'href' => $href,\n        ]);\n    }\n\n    public function get_alternate_links()\n    {\n        $html = '';\n        foreach (self::find_all_by_discoverable(1) as $feed) {\n            if ($feed->id !== $this->id) {\n                $html .= \"\\n\\t\".self::get_link_tag([\n                    'prefix' => 'atom',\n                    'rel' => 'alternate',\n                    'type' => $feed->get_content_type(),\n                    'title' => \\Podlove\\Feeds\\prepare_for_feed($feed->title_for_discovery()),\n                    'href' => $feed->get_contextual_subscribe_url(),\n                ]);\n            }\n        }\n\n        return apply_filters('podlove_feed_alternate_links', $html);\n    }\n\n    public static function get_link_tag($args = [])\n    {\n        $defaults = [\n            'prefix' => null,\n            'rel' => 'alternate',\n            'type' => 'application/atom+xml',\n            'title' => '',\n            'href' => '',\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        $tag_name = $args['prefix'] ? $args['prefix'].':link' : 'link';\n\n        return sprintf(\n            '<%s%s%s%s href=\"%s\" />',\n            $tag_name,\n            $args['rel'] ? ' rel=\"'.$args['rel'].'\"' : '',\n            $args['type'] ? ' type=\"'.$args['type'].'\"' : '',\n            $args['title'] ? ' title=\"'.$args['title'].'\"' : '',\n            $args['href']\n        );\n    }\n\n    /**\n     * Get the SQL LIMIT segment for this feed.\n     *\n     * Depending on settings it can be LIMIT <num> or empty.\n     *\n     * @param mixed $posts_per_page\n     *\n     * @return string\n     */\n    public function get_post_limit_sql($posts_per_page = false)\n    {\n        if ($posts_per_page === false) {\n            $posts_per_page = (int) $this->limit_items;\n        }\n\n        if ($posts_per_page === self::ITEMS_WP_LIMIT) {\n            $posts_per_page = (int) get_option('posts_per_rss');\n        }\n\n        if ($posts_per_page > 0) {\n            return $posts_per_page;\n        }\n\n        // no limit\n        if ($posts_per_page === self::ITEMS_NO_LIMIT) {\n            return '';\n        }\n\n        if ($posts_per_page === self::ITEMS_GLOBAL_LIMIT) {\n            $podcast = Podcast::get();\n            if ((int) $podcast->limit_items !== self::ITEMS_GLOBAL_LIMIT) {\n                return $this->get_post_limit_sql($podcast->limit_items);\n            }\n        }\n\n        // default to no limit; however, this should never happen\n        return '';\n    }\n\n    public static function find_duplicate_slugs()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\tSELECT slug, GROUP_CONCAT(`id`) ids, COUNT(*) cnt\n\t\tFROM '.self::table_name().'\n\t\tGROUP BY slug\n\t\tHAVING cnt > 1';\n\n        if (!$rows = $wpdb->get_results($sql, ARRAY_A)) {\n            return [];\n        }\n\n        return array_map(function ($row) {\n            return [\n                'slug' => $row['slug'],\n                'feed_ids' => explode(',', $row['ids']),\n            ];\n        }, $rows);\n    }\n}\n\nFeed::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nFeed::property('episode_asset_id', 'INT');\nFeed::property('itunes_feed_id', 'INT');\nFeed::property('name', 'VARCHAR(255)');\nFeed::property('title', 'VARCHAR(255)');\nFeed::property('slug', 'VARCHAR(255)');\nFeed::property('position', 'FLOAT');\nFeed::property('redirect_url', 'VARCHAR(255)');\nFeed::property('redirect_http_status', 'INT');\nFeed::property('enable', 'INT');\nFeed::property('discoverable', 'INT');\nFeed::property('limit_items', 'INT');\nFeed::property('embed_content_encoded', 'INT');\nFeed::property('optimize_content_encoded_html', 'TINYINT(1)');\nFeed::property('append_name_to_podcast_title', 'TINYINT(1)');\nFeed::property('protected', 'TINYINT(1)');\nFeed::property('protection_type', 'TINYINT(1)'); // Protection type: 0: local, 1: WordPress User\nFeed::property('protection_user', 'VARCHAR(60)');\nFeed::property('protection_password', 'VARCHAR(64)');\n"
  },
  {
    "path": "lib/model/file_type.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass FileType extends Base\n{\n    public function title()\n    {\n        return $this->name.' ('.$this->extension.')';\n    }\n\n    public static function get_types()\n    {\n        global $wpdb;\n\n        return $wpdb->get_col('SELECT DISTINCT `type` FROM '.FileType::table_name());\n    }\n}\n\nFileType::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nFileType::property('name', 'VARCHAR(255)');\nFileType::property('type', 'VARCHAR(255)');\nFileType::property('mime_type', 'VARCHAR(255)');\nFileType::property('extension', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/model/geo_area.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass GeoArea extends Base {}\n\nGeoArea::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nGeoArea::property('geoname_id', 'INT', ['unique' => true]);\nGeoArea::property('parent_id', 'INT');\nGeoArea::property('code', 'VARCHAR(5)');\nGeoArea::property('type', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/model/geo_area_name.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass GeoAreaName extends Base {}\n\nGeoAreaName::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nGeoAreaName::property('area_id', 'INT');\nGeoAreaName::property('language', 'VARCHAR(5)');\nGeoAreaName::property('name', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/model/image.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Log;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * Image Object.\n *\n * Usage\n *\n *     // get url, resized to 100px width, keep aspect ratio\n *     $image = (new Image($url))->setWidth(100)->url();\n *\n *     // get url, resized to 100px width and 50px height, cropped\n *     $image = (new Image($url))\n *      ->setWidth(100)\n *       ->setHeight(50)\n *           ->setCrop(true)\n *         ->url();\n *\n *   // get image tag with custom alt text and title\n *   $image = (new Image($url))->image([\"alt\" => \"custom alt\", \"title\" => \"custom title\"]);\n */\nclass Image\n{\n    // URL/file properties\n    private $id;\n    private $source_url;\n    private $file_name;\n    private $file_extension;\n    private $upload_basedir;\n    private $upload_baseurl;\n\n    // image properties\n    private $crop = false;\n    private $width;\n    private $height;\n\n    // html rendering properties\n    private $retina = true;\n\n    /**\n     * Create image object.\n     *\n     * Manage remote image objects. Cache locally so we can resize and serve\n     * optimized image dimensions.\n     *\n     * @param string $url       Remote image URL\n     * @param mixed  $file_name\n     */\n    public function __construct($url, $file_name = '')\n    {\n        // FIXME: if $file_name is empty, the url will not work. I must not treat this silently!\n        $this->source_url = trim($url ?? '');\n        $this->file_name = sanitize_title($file_name);\n\n        // manually remove troublemaking characters\n        // @see https://community.podlove.org/t/solved-kind-of-cover-art-disappears-caching-issue/478/\n        // @see https://sendegate.de/t/problem-mit-caching-von-grafiken/2947\n        if (function_exists('iconv')) {\n            $this->file_name = iconv('UTF-8', 'ASCII//TRANSLIT', $this->file_name);\n        }\n        $this->file_name = preg_replace('~[^-a-z0-9_]+~', '', $this->file_name);\n\n        $this->file_extension = $this->extract_file_extension();\n        $this->id = md5($url.$this->file_name);\n\n        // create subdirectories to avoid too many directories in the root directory\n        $id_directory = substr($this->id, 0, 2).'/'.substr($this->id, 2);\n\n        $this->upload_basedir = self::cache_dir().$id_directory;\n        $this->upload_baseurl = content_url('cache/podlove/').$id_directory;\n    }\n\n    public function source_url()\n    {\n        return $this->source_url;\n    }\n\n    public static function cache_dir()\n    {\n        return trailingslashit(WP_CONTENT_DIR).'cache/podlove/';\n    }\n\n    /**\n     * Delete all image caches.\n     */\n    public static function flush_cache()\n    {\n        $dir = self::cache_dir();\n\n        if (!file_exists($dir)) {\n            return;\n        }\n\n        $it = new \\RecursiveDirectoryIterator($dir, \\RecursiveDirectoryIterator::SKIP_DOTS);\n        $files = new \\RecursiveIteratorIterator($it, \\RecursiveIteratorIterator::CHILD_FIRST);\n        foreach ($files as $file) {\n            if ($file->isDir()) {\n                rmdir($file->getRealPath());\n            } else {\n                wp_delete_file($file->getRealPath());\n            }\n        }\n        rmdir($dir);\n    }\n\n    /**\n     * Set to true if resizing should crop when necessary.\n     *\n     * @param bool $crop crop image if given dimensions deviate from original aspect ratio\n     *\n     * @return $this for chaining\n     */\n    public function setCrop($crop)\n    {\n        $this->crop = (bool) $crop;\n\n        return $this;\n    }\n\n    public function setWidth($width)\n    {\n        if (!$width) {\n            return $this;\n        }\n\n        $this->width = (int) $width;\n\n        return $this;\n    }\n\n    public function setHeight($height)\n    {\n        if (!$height) {\n            return $this;\n        }\n\n        $this->height = (int) $height;\n\n        return $this;\n    }\n\n    public function setRetina($retina)\n    {\n        $this->retina = (bool) $retina;\n\n        return $this;\n    }\n\n    /**\n     * Get URL for resized image.\n     *\n     * Examples\n     *\n     *     $image->url(); // returns image URL\n     *\n     * @return string image URL\n     */\n    public function url()\n    {\n        if (empty($this->source_url)) {\n            return null;\n        }\n\n        if ($this->extract_file_extension() == 'svg') {\n            return $this->source_url;\n        }\n\n        // In case the image cache doesn't work, it can be deactivated by\n        // defining the PHP constant PODLOVE_DISABLE_IMAGE_CACHE = true.\n        // It's not recommended since that leads to all images being delivered full size\n        // instead of optimized resolutions.\n        if (defined('PODLOVE_DISABLE_IMAGE_CACHE') && PODLOVE_DISABLE_IMAGE_CACHE) {\n            return $this->source_url;\n        }\n\n        // if neither width nor height are available something went horribly wrong,\n        // so we better bail and return the source url instead\n        if (!$this->width && !$this->height) {\n            return $this->source_url;\n        }\n\n        if (!$this->file_extension) {\n            Log::get()->addWarning(sprintf(__('Unable to determine file extension for %s.'), $this->source_url));\n\n            return apply_filters('podlove_image_url', $this->source_url);\n        }\n\n        // when PODLOVE_IMAGE_CACHE_FORCE_DYNAMIC_URL is set to true, the static\n        // \"physical\" URL is never exposed, only the dynamic URL. This can be\n        // helpful when page caches keep serving the static URL even though it\n        // does not exist for some reason. The dynamic URL always works.\n        // Drawback is that serving with the dynamic URL is a bit slower because\n        // it has to go through the PHP stack.\n        $force_dynamic_url = defined('PODLOVE_IMAGE_CACHE_FORCE_DYNAMIC_URL') && PODLOVE_IMAGE_CACHE_FORCE_DYNAMIC_URL;\n\n        if (!$force_dynamic_url && file_exists($this->resized_file())) {\n            $url = $this->resized_url();\n        } else {\n            $source_url = \\Podlove\\PHP\\str2hex($this->source_url);\n            $width = (int) $this->width;\n            $height = (int) $this->height;\n            $crop = (int) $this->crop;\n            $file_name = urlencode($this->file_name);\n\n            // FIXME: some generated urls have an empty $file_name, which leads to the url not resolving\n            // example: (new \\Podlove\\Model\\Image(\"https://pbs.twimg.com/media/Dz2U-vTXgAATlqd.jpg:large\"))->setWidth(100)->url();\n            if (get_option('permalink_structure')) {\n                $path = '/podlove/image/'\n                    .$source_url\n                    .'/'.$width\n                    .'/'.$height\n                    .'/'.$crop\n                    .'/'.$file_name;\n            } else {\n                $path = add_query_arg([\n                    'podlove_image_cache_url' => $source_url,\n                    'podlove_width' => $width,\n                    'podlove_height' => $height,\n                    'podlove_crop' => $crop,\n                    'podlove_file_name' => $file_name,\n                ], 'index.php');\n            }\n\n            $url = home_url($path);\n        }\n\n        return apply_filters('podlove_image_url', $url);\n    }\n\n    /**\n     * Get HTML image tag for resized image.\n     *\n     * Examples\n     *\n     *     $image->image(); // returns image tag\n     *\n     * @param array $args List of arguments\n     *                    - id: Set image tag \"id\" attribute.\n     *                    - class: Set image tag \"class\" attribute.\n     *                    - style: Set image tag \"style\" attribute.\n     *                    - alt: Set image tag \"alt\" attribute.\n     *                    - title: Set image tag \"title\" attribute.\n     *                    - width: Image width. Set width and leave height blank to keep the orinal aspect ratio.\n     *                    - height: Image height. Set height and leave width blank to keep the orinal aspect ratio.\n     *                    - attributes: List of other HTML attributes, for example: ['data-foo' => 'bar']\n     *\n     * @return string HTML image tag\n     */\n    public function image($args = [])\n    {\n        $defaults = [\n            'id' => '',\n            'class' => '',\n            'style' => '',\n            'alt' => '',\n            'title' => '',\n            'width' => $this->width,\n            'height' => $this->height,\n            'attributes' => [],\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        // put everything in 'attributes' for easy iteration\n        foreach (['id', 'class', 'style', 'alt', 'title', 'width', 'height'] as $attr) {\n            if ($args[$attr]) {\n                $args['attributes'][$attr] = $args[$attr];\n            }\n        }\n\n        $dom = new \\Podlove\\DomDocumentFragment();\n        $img = $dom->createElement('img');\n\n        foreach ($args['attributes'] as $key => $value) {\n            $img->setAttribute($key, $value);\n        }\n\n        $img->setAttribute('src', $this->url());\n\n        if ($this->retina && $srcset = $this->srcset()) {\n            $img->setAttribute('srcset', $srcset);\n        }\n\n        $dom->appendChild($img);\n\n        return (string) $dom;\n    }\n\n    public function file_name($size_slug)\n    {\n        if ($this->file_name) {\n            return $this->file_name.'_'.$size_slug.'.'.$this->file_extension;\n        }\n\n        return $size_slug.'.'.$this->file_extension;\n    }\n\n    public function source_exists()\n    {\n        return is_file($this->original_file());\n    }\n\n    public function original_file()\n    {\n        return implode(DIRECTORY_SEPARATOR, [$this->upload_basedir, $this->file_name('original')]);\n    }\n\n    public function resized_file()\n    {\n        return implode(DIRECTORY_SEPARATOR, [$this->upload_basedir, $this->file_name($this->size_slug())]);\n    }\n\n    public function generate_resized_copy()\n    {\n        if (!\\Podlove\\is_image($this->original_file(), basename($this->source_url))) {\n            Log::get()->addWarning('Podlove Image Cache: Not an image ('.$this->original_file().')');\n\n            return;\n        }\n\n        $editor_args = [\n            'mime_type' => \\Podlove\\get_image_mime_type(\\Podlove\\get_image_type($this->original_file())),\n        ];\n\n        $image = wp_get_image_editor($this->original_file(), $editor_args);\n\n        if (is_wp_error($image)) {\n            Log::get()->addWarning('Podlove Image Cache: Unable to resize (1). '.$image->get_error_message());\n\n            return;\n        }\n\n        $orig_sizes = $image->get_size();\n        $original = $orig_sizes['width'].'x'.$orig_sizes['height'];\n\n        if (!$this->height) {\n            $this->height = $orig_sizes['height'];\n        }\n\n        if (!$this->width) {\n            $this->width = $orig_sizes['width'];\n        }\n\n        $result = $image->resize($this->width, $this->height, $this->crop);\n\n        if (is_wp_error($result)) {\n            Log::get()->addWarning('Podlove Image Cache: Unable to resize (2, w '.$this->width.' h '.$this->height.' ... original: '.$original.'). '.$result->get_error_message());\n\n            return;\n        }\n\n        $result = $image->save($this->resized_file());\n\n        if (is_wp_error($result)) {\n            Log::get()->addWarning('Podlove Image Cache: Unable to resize (3). '.$result->get_error_message());\n\n            return;\n        }\n\n        // working around WordPress oddities: if WP saves the file under a name\n        // other than the one we want ... rename the file.\n        if ($result['path'] != $this->resized_file()) {\n            @rename($result['path'], $this->resized_file());\n        }\n\n        // when a new image size is created, Template Cache must be cleared\n        TemplateCache::get_instance()->setup_global_purge();\n    }\n\n    public function redownload_source()\n    {\n        $this->download_source();\n        $this->delete_resized_versions();\n    }\n\n    public function download_source()\n    {\n        $source_url = $this->source_url;\n        $current_url = $this->source_url;\n\n        $source_domain = wp_parse_url($source_url, PHP_URL_HOST);\n        $current_domain = explode(':', $_SERVER['HTTP_HOST'])[0];\n\n        // if domains match, see if the image is part of the Publisher\n        // and can be copied on the filesystem, skipping http\n        if ($current_domain == $source_domain) {\n            $plugin_dirname = basename(\\Podlove\\PLUGIN_DIR, true);\n\n            if (stristr($source_url, $plugin_dirname)) {\n                $path = explode($plugin_dirname, $source_url)[1];\n                $file = untrailingslashit(\\Podlove\\PLUGIN_DIR).$path;\n\n                if (file_exists($file) && \\Podlove\\is_image($file, basename($this->source_url))) {\n                    $this->create_basedir();\n                    $this->save_cache_data();\n                    $this->copy_as_original_file($file);\n\n                    return;\n                }\n            }\n        }\n\n        /**\n         * The following section is only reached if the downloaded image is not part of the Publisher.\n         */\n\n        // for download_url()\n        require_once ABSPATH.'wp-admin/includes/file.php';\n\n        $result = self::download_url($this->source_url);\n\n        // TODO idea:\n        // - whenever an image fetch fails, blacklist that URL from image caching\n        // - when more than 100(?) URLs are blacklisted, deactivate image caching per setting\n        // - when that setting is set, display an info somewhere why that is, what it is and what to do about it\n\n        if (is_wp_error($result)) {\n            Log::get()->addWarning(\n                sprintf(__('Podlove Image Cache: Unable to download image. %s.'), $result->get_error_message()),\n                ['url' => $this->source_url]\n            );\n\n            return;\n        }\n\n        list($temp_file, $response) = $result;\n\n        if (is_wp_error($temp_file)) {\n            Log::get()->addWarning(\n                sprintf(__('Podlove Image Cache: Unable to download image. %s.'), $temp_file->get_error_message()),\n                ['url' => $this->source_url]\n            );\n        }\n\n        if (!\\Podlove\\is_image($temp_file, basename($this->source_url))) {\n            Log::get()->addWarning(\n                sprintf(__('Podlove Image Cache: Downloaded file is not an image.')),\n                ['url' => $this->source_url]\n            );\n            wp_delete_file($temp_file);\n\n            return;\n        }\n\n        $this->create_basedir();\n        $this->save_cache_data($response);\n        $this->move_as_original_file($temp_file);\n        wp_delete_file($temp_file);\n        $this->add_donotbackup_dotfile();\n    }\n\n    public function create_basedir()\n    {\n        if (!wp_mkdir_p($this->upload_basedir)) {\n            Log::get()->addWarning(\n                sprintf(\n                    __('Podlove Image Cache: Unable to create directory %s. Is its parent directory writable by the server?'),\n                    $this->upload_basedir\n                )\n            );\n        }\n    }\n\n    public function move_as_original_file($file)\n    {\n        $move_new_file = @rename($file, $this->original_file());\n\n        if (false === $move_new_file) {\n            Log::get()->addWarning(\n                sprintf(\n                    __('Podlove Image Cache: The downloaded image could not be moved to %s.'),\n                    $this->original_file()\n                )\n            );\n        }\n    }\n\n    public function copy_as_original_file($file)\n    {\n        $move_new_file = @copy($file, $this->original_file());\n\n        if (false === $move_new_file) {\n            Log::get()->addWarning(\n                sprintf(\n                    __('Podlove Image Cache: The downloaded image could not be moved to %s.'),\n                    $this->original_file()\n                )\n            );\n        }\n    }\n\n    /**\n     * Downloads a url to a local temporary file using the WordPress HTTP Class.\n     * Please note, That the calling function must unlink() the file.\n     *\n     * This is a modified copy of WP Core download_url().\n     * I copied it because I need to look into the header of the response but\n     * unfortunately the original implementation does not expose it.\n     *\n     * @param string $url        the URL of the file to download\n     * @param int    $timeout    The timeout for the request to download the file default 300 seconds\n     * @param mixed  $extra_args\n     *\n     * @return mixed WP_Error on failure, array with Filename & http response on success\n     */\n    public static function download_url($url, $timeout = 300, $extra_args = [])\n    {\n        // WARNING: The file is not automatically deleted, The script must unlink() the file.\n        if (!$url) {\n            return new \\WP_Error('http_no_url', __('Invalid URL Provided.'));\n        }\n\n        $tmpfname = wp_tempnam($url);\n        if (!$tmpfname) {\n            return new \\WP_Error('http_no_file', __('Could not create Temporary file.'));\n        }\n\n        $default_args = [\n            'timeout' => $timeout,\n            'stream' => true,\n            'filename' => $tmpfname,\n            'sslverify' => \\Podlove\\get_setting('website', 'ssl_verify_peer') == 'on',\n        ];\n        $args = array_merge($default_args, $extra_args);\n\n        $response = wp_safe_remote_get($url, $args);\n\n        if (is_wp_error($response)) {\n            wp_delete_file($tmpfname);\n\n            return $response;\n        }\n\n        if (200 != wp_remote_retrieve_response_code($response)) {\n            wp_delete_file($tmpfname);\n\n            return new \\WP_Error('http_404', trim(wp_remote_retrieve_response_message($response)));\n        }\n\n        return [$tmpfname, $response];\n    }\n\n    /**\n     * Generate srcset attribute for img tag.\n     *\n     * @return null|string\n     */\n    private function srcset()\n    {\n        $file = $this->original_file();\n\n        if (!file_exists($file)) {\n            return null;\n        }\n\n        @list($max_width, $max_height) = getimagesize($file);\n\n        if ($this->width * 2 > $max_width) {\n            return null;\n        }\n\n        $sizes = ['1x' => $this->url()];\n\n        if ($this->width * 2 <= $max_width) {\n            $img2x = (new Image($this->source_url, $this->file_name))\n                ->setCrop($this->crop)\n                ->setRetina($this->retina)\n                ->setWidth($this->width * 2)\n            ;\n\n            $sizes['2x'] = $img2x->url();\n        }\n\n        if ($this->width * 3 <= $max_width) {\n            $img3x = (new Image($this->source_url, $this->file_name))\n                ->setCrop($this->crop)\n                ->setRetina($this->retina)\n                ->setWidth($this->width * 3)\n            ;\n\n            $sizes['3x'] = $img3x->url();\n        }\n\n        $sources = [];\n        foreach ($sizes as $factor => $url) {\n            $sources[] = $url.' '.$factor;\n        }\n\n        return implode(', ', $sources);\n    }\n\n    private function cache_file()\n    {\n        return implode(DIRECTORY_SEPARATOR, [$this->upload_basedir, 'cache.yml']);\n    }\n\n    private function original_url()\n    {\n        return implode('/', [$this->upload_baseurl, $this->file_name('original')]);\n    }\n\n    private function resized_url()\n    {\n        return implode('/', [$this->upload_baseurl, $this->file_name($this->size_slug())]);\n    }\n\n    private function size_slug()\n    {\n        $crop = $this->crop ? 'c' : '';\n\n        if ($this->width || $this->height) {\n            return $this->width.'x'.$this->height.$crop;\n        }\n\n        return 'original';\n    }\n\n    private function delete_resized_versions()\n    {\n        $resized_versions = implode(DIRECTORY_SEPARATOR, [$this->upload_basedir, '*x*.*']);\n        array_map('unlink', glob($resized_versions));\n    }\n\n    private function add_donotbackup_dotfile()\n    {\n        file_put_contents(\n            trailingslashit(self::cache_dir()).'.donotbackup',\n            \"Backup plugins are encouraged to not backup folders and subfolders when this file is inside.\\n\"\n        );\n    }\n\n    /**\n     * Save data relevant for cache invalidation to file.\n     *\n     * @param array $response\n     */\n    private function save_cache_data($response = [])\n    {\n        $cache_info = [\n            'source' => $this->source_url,\n            'filename' => $this->file_name,\n        ];\n\n        if (!empty($response)) {\n            $cache_info['etag'] = wp_remote_retrieve_header($response, 'etag');\n            $cache_info['last-modified'] = wp_remote_retrieve_header($response, 'last-modified');\n            $cache_info['expires'] = wp_remote_retrieve_header($response, 'expires');\n        }\n\n        file_put_contents($this->cache_file(), Yaml::dump($cache_info));\n    }\n\n    private function extract_file_extension()\n    {\n        $url = wp_parse_url($this->source_url);\n\n        if (isset($url['path'])) {\n            return pathinfo($url['path'], PATHINFO_EXTENSION);\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "lib/model/job.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass Job extends Base\n{\n    use KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function is_finished()\n    {\n        return $this->steps_progress >= $this->steps_total;\n    }\n\n    public static function find_one_recent_job($job_class)\n    {\n        // get class name without namespace\n        $job_class_name = explode('\\\\', $job_class);\n        $job_class_name = end($job_class_name);\n\n        $sql = '\n\t\t\tSELECT \n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.Job::table_name().' j\n\t\t\tWHERE `class` LIKE \"%'.$job_class_name.'\"\n\t\t\tORDER BY `updated_at` DESC\n\t\t\tLIMIT 0,1\n\t\t';\n\n        return Job::find_one_by_sql($sql);\n    }\n\n    public static function find_one_recent_finished_job($job_class)\n    {\n        // get class name without namespace\n        $job_class_name = explode('\\\\', $job_class);\n        $job_class_name = end($job_class_name);\n\n        $sql = '\n\t\t\tSELECT \n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.Job::table_name().' j\n\t\t\tWHERE `class` LIKE \"%'.$job_class_name.'\" AND steps_total <= steps_progress\n\t\t\tORDER BY `updated_at` DESC\n\t\t\tLIMIT 0,1\n\t\t';\n\n        return Job::find_one_by_sql($sql);\n    }\n\n    public static function find_one_recent_unfinished_job($job_class)\n    {\n        // get class name without namespace\n        $job_class_name = explode('\\\\', $job_class);\n        $job_class_name = end($job_class_name);\n\n        $sql = '\n\t\t\tSELECT \n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.Job::table_name().' j\n\t\t\tWHERE `class` LIKE \"%'.$job_class_name.'\" AND steps_total > steps_progress\n\t\t\tORDER BY `updated_at` DESC\n\t\t\tLIMIT 0,1\n\t\t';\n\n        return Job::find_one_by_sql($sql);\n    }\n\n    public static function find_next_in_queue()\n    {\n        $sql = '\n\t\t\tSELECT\n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.self::table_name().'\n\t\t\tWHERE\n\t\t\t\tsteps_total > steps_progress\n\t\t\tORDER BY created_at ASC\n\t\t\tLIMIT 0, 1\n\t\t';\n\n        return self::find_one_by_sql($sql);\n    }\n\n    public static function find_recently_finished_jobs($limit = 10)\n    {\n        $sql = '\n\t\t\tSELECT\n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.Job::table_name().'\n\t\t\tWHERE\n\t\t\t\tsteps_total <= steps_progress\n\t\t\tORDER BY created_at DESC\n\t\t\tLIMIT 0, '.(int) $limit.'\n\t\t';\n\n        return Job::find_all_by_sql($sql);\n    }\n\n    public static function find_running_jobs()\n    {\n        $sql = '\n\t\t\tSELECT\n\t\t\t\t*\n\t\t\tFROM\n\t\t\t\t'.Job::table_name().'\n\t\t\tWHERE\n\t\t\t\tsteps_total > steps_progress\n\t\t\tORDER BY created_at ASC\n\t\t';\n\n        return Job::find_all_by_sql($sql);\n    }\n\n    public static function load($id)\n    {\n        $job = self::find_by_id($id);\n\n        if (!$job) {\n            return null;\n        }\n\n        $job->args = maybe_unserialize($job->args);\n        $job->state = maybe_unserialize($job->state);\n\n        $classname = $job->class;\n\n        if (!class_exists($classname)) {\n            $classname = str_replace('\\\\\\\\', '\\\\', $classname);\n        }\n\n        return new $classname($job->args, $job);\n    }\n\n    public static function clean()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tDELETE FROM\n\t\t\t\t'.self::table_name().'\n\t\t\tWHERE\n\t\t\t\tcreated_at < \"'.date('Y-m-d H:i:s', strtotime('-2 weeks')).'\"\n\t    ';\n\n        $wpdb->query($sql);\n    }\n\n    /**\n     * Dedicated method to update state object.\n     *\n     * Necessary because of \"Indirect modification of overloaded property\n     * has no effect\" warning if state is updated normally via\n     * $this->state['arg'] = 'newvalue';\n     *\n     * @see  http://stackoverflow.com/a/19749730/72448.\n     *\n     * @param string $attribute\n     * @param mixed  $value\n     */\n    public function update_state($attribute, $value)\n    {\n        $state = $this->state;\n        $state[$attribute] = $value;\n        $this->state = $state;\n    }\n\n    /**\n     * Triggered when a job runner wakes a job.\n     */\n    public function increase_wakeup_count()\n    {\n        global $wpdb;\n        ++$this->wakeups;\n        $wpdb->query('UPDATE '.self::table_name().' SET wakeups = wakeups + 1 WHERE id = '.(int) $this->id);\n    }\n\n    /**\n     * Triggered when job runner stops/finishes a job.\n     */\n    public function increase_sleep_count()\n    {\n        global $wpdb;\n        ++$this->sleeps;\n        $wpdb->query('UPDATE '.self::table_name().' SET sleeps = sleeps + 1 WHERE id = '.(int) $this->id);\n    }\n}\n\nJob::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nJob::property('class', 'VARCHAR(255)');\nJob::property('args', 'LONGTEXT');\nJob::property('steps_total', 'INT');\nJob::property('steps_progress', 'INT');\nJob::property('active_run_time', 'FLOAT');\nJob::property('state', 'LONGTEXT');\nJob::property('wakeups', 'INT'); // how often did the job get woken up by runner\nJob::property('sleeps', 'INT');  // how often did the job run complete successfully\nJob::property('created_at', 'DATETIME');\nJob::property('updated_at', 'DATETIME');\n"
  },
  {
    "path": "lib/model/keeps_blog_reference_trait.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Keep reference to a blog id.\n *\n * Example usage:\n *\n * class MyModel {\n *   use KeepsBlogReferenceTrait;\n *\n *   public function __construct() { $this->set_blog_id(); }\n * }\n */\ntrait KeepsBlogReferenceTrait\n{\n    private $blog_id;\n\n    public function set_blog_id($blog_id = null)\n    {\n        $this->blog_id = $blog_id !== null ? $blog_id : get_current_blog_id();\n    }\n\n    public function get_blog_id()\n    {\n        return $this->blog_id;\n    }\n\n    public function with_blog_scope($callback)\n    {\n        $result = null;\n\n        if ($this->blog_id != get_current_blog_id()) {\n            switch_to_blog($this->blog_id);\n            $result = $callback();\n            restore_current_blog();\n        } else {\n            $result = $callback();\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "lib/model/licensable.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\ninterface Licensable\n{\n    public function get_license();\n\n    public function get_license_picture_url();\n\n    public function get_license_html();\n}\n"
  },
  {
    "path": "lib/model/license.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nclass License\n{\n    // \"podcast\" or \"episode\"\n    public $scope;\n\n    public $type;\n    public $name;\n    public $url;\n    public $modifcation;\n    public $commercial_use;\n    public $jurisdiction;\n    public $version;\n    public $modification;\n\n    public function __construct($scope, $attributes)\n    {\n        $license = self::get_license_from_url($attributes['license_url']);\n\n        $this->scope = $scope;\n        $this->type = self::getLicenseType($attributes['license_url'], $attributes['license_name']);\n        $this->name = $attributes['license_name'];\n        $this->url = $attributes['license_url'];\n        $this->version = $license['version'];\n        $this->modification = $license['modification'];\n        $this->commercial_use = $license['commercial_use'];\n        $this->jurisdiction = $license['jurisdiction'];\n    }\n\n    public function getIdentifier()\n    {\n        if ($this->version == 'pdmark') {\n            return 'PDM-1.0';\n        }\n\n        if ($this->version == 'cc0') {\n            return 'CC0-1.0';\n        }\n\n        if ($this->getLicenseType($this->url, $this->name) != 'cc') {\n            return $this->name;\n        }\n\n        $commercial_segment = match ($this->commercial_use) {\n            'yes' => false,\n            default => 'nc'\n        };\n\n        $modification_segment = match ($this->modification) {\n            'yes' => false,\n            'no' => 'nd',\n            default => 'sa'\n        };\n\n        $verison_segment = $this->version == 'cc3' ? '3.0' : '4.0';\n\n        $segments = [\n            'cc',\n            'by',\n            $commercial_segment,\n            $modification_segment,\n            $verison_segment\n        ];\n\n        if ($this->version == 'cc3' && $this->jurisdiction != 'international') {\n            $segments[] = $this->jurisdiction;\n        }\n\n        $segments = array_filter($segments);\n\n        return implode('-', $segments);\n    }\n\n    public function getLicenseType($url, $name)\n    {\n        if (empty($url) || empty($name)) {\n            return;\n        }\n\n        if (self::is_cc_license($url, $name)) {\n            return 'cc';\n        }\n\n        return 'other';\n    }\n\n    public function getName()\n    {\n        return $this->name;\n    }\n\n    public function getUrl()\n    {\n        return $this->url;\n    }\n\n    public function isCreativeCommons()\n    {\n        return $this->type == 'cc';\n    }\n\n    public function getHtml()\n    {\n        if ($this->type == 'cc') {\n            return '\n\t\t\t<div class=\"podlove_cc_license\">\n\t\t\t\t<img src=\"'.$this->getPictureUrl().'\" alt=\"License\" />\n\t\t\t\t<p>\n\t\t\t\t\tThis work is licensed under a <a rel=\"license\" href=\"'.$this->getUrl().'\">'.$this->getName().'</a>.\n\t\t\t\t</p>\n\t\t\t</div>';\n        }\n\n        if ($this->type == 'other') {\n            return '\n\t\t\t<div class=\"podlove_license\">\n\t\t\t\t<p>\n\t\t\t\t\t'.sprintf(\n                __('This work is licensed under the %s license.', 'podlove-podcasting-plugin-for-wordpress'),\n                '<a rel=\"license\" href=\"'.$this->url.'\">'.$this->name.'</a>'\n            ).'\n\t\t\t\t</p>\n\t\t\t</div>';\n        }\n\n        // episodes fall back to podcast licenses\n        if ($this->scope == 'episode') {\n            return Podcast::get()->get_license_html();\n        }\n\n        // ... otherwise, a license is missing\n        return \"\n\t\t<div class=\\\"podlove_license\\\">\n\t\t\t\t<p style='color: red;'>\n\t\t\t\t\t\".__('This work is (not yet) licensed, as no license was chosen.', 'podlove-podcasting-plugin-for-wordpress').'\n\t\t\t\t</p>\n\t\t</div>';\n    }\n\n    public function getPictureUrl()\n    {\n        if ($this->type != 'cc') {\n            throw new \\Exception('Only cc licenses have pictures');\n        }\n\n        if ($this->version == 'cc0') {\n            return \\Podlove\\PLUGIN_URL\n            .'/images/cc/pd.png';\n        }\n\n        if ($this->version == 'pdmark') {\n            return \\Podlove\\PLUGIN_URL\n            .'/images/cc/pdmark.png';\n        }\n\n        return \\Podlove\\PLUGIN_URL\n            .'/images/cc/'\n            .$this->getAllowModificationId()\n            .'_'\n            .$this->getAllowCommercialUseId()\n            .'.png';\n    }\n\n    public static function is_cc_license($license_url, $license_name)\n    {\n        if (!is_string($license_url)) {\n            return;\n        }\n\n        if (strpos($license_url, 'creativecommons.org') === false || strpos($license_name, 'Creative Commons') === false && strpos($license_name, 'Public Domain') === false) {\n            return false;\n        }\n\n        return true;\n    }\n\n    public static function get_license_from_url($url)\n    {\n        // only parse cc licenses\n        if (stripos($url, 'creativecommons.org') === false) {\n            return [\n                'version' => null,\n                'commercial_use' => null,\n                'modification' => null,\n                'jurisdiction' => null,\n            ];\n        }\n\n        $raw_extract = array_slice(\n            explode('/', $url),\n            4, // remove http://creativecommons.org/\n            3\n        );\n\n        if (stripos($url, '/publicdomain/zero/')) {\n            $version = 'cc0';\n        } elseif (stripos($url, '/publicdomain/mark/')) {\n            $version = 'pdmark';\n        } elseif (stripos($url, '/4.0')) {\n            $version = 'cc4';\n        } else {\n            $version = 'cc3';\n        }\n\n        return [\n            'version' => $version,\n            'commercial_use' => strpos($raw_extract[0], 'nc') ? 'no' : 'yes',\n            'modification' => self::get_modification_state($raw_extract[0]),\n            'jurisdiction' => !isset($raw_extract[2]) || strpos($raw_extract[2], '.') || $raw_extract[2] == '' ? 'international' : $raw_extract[2],\n        ];\n    }\n\n    public static function get_name_from_license($license)\n    {\n        $locales = \\Podlove\\License\\locales_cc();\n        $versions = \\Podlove\\License\\version_per_country_cc();\n\n        $license_attributions = '';\n\n        if (empty($license['version'])) {\n            return '';\n        }\n\n        if ($license['version'] == 'pdmark') {\n            return 'Public Domain Mark License';\n        }\n\n        if ($license['version'] == 'cc0') {\n            return 'Public Domain License';\n        }\n\n        if ($license['commercial_use'] == 'no') {\n            $license_attributions .= '-NonCommercial';\n        }\n\n        if ($license['modification'] == 'no') {\n            $license_attributions .= '-NoDerivatives';\n        }\n\n        if ($license['modification'] == 'yesbutshare') {\n            $license_attributions .= '-ShareAlike';\n        }\n\n        if ($license['version'] == 'cc4') {\n            return 'Creative Commons Attribution'.$license_attributions.' 4.0 International License';\n        }\n\n        return 'Creative Commons Attribution'.$license_attributions.' '.$versions[$license['jurisdiction']]['version'].' '.($license['jurisdiction'] == 'international' ? 'Unported' : $locales[$license['jurisdiction']]).' License';\n    }\n\n    public static function get_url_from_license($license)\n    {\n        $versions = \\Podlove\\License\\version_per_country_cc();\n\n        if (!is_array($license)) {\n            return;\n        }\n\n        if ($license['version'] == 'cc0') {\n            return 'http://creativecommons.org/publicdomain/zero/1.0/';\n        }\n\n        if ($license['version'] == 'pdmark') {\n            return 'http://creativecommons.org/publicdomain/mark/1.0/';\n        }\n\n        if ($license['version'] == 'cc4') {\n            return 'http://creativecommons.org/licenses/by'\n                    .($license['commercial_use'] == 'no' ? '-nc' : '')\n                    .($license['modification'] == 'yes' ? '/' : ($license['modification'] == 'no' ? '-nd/' : '-sa/'))\n                    .'4.0';\n        }\n\n        return 'http://creativecommons.org/licenses/by'\n                .($license['commercial_use'] == 'no' ? '-nc' : '')\n                .($license['modification'] == 'yes' ? '/' : ($license['modification'] == 'no' ? '-nd/' : '-sa/'))\n                .$versions[$license['jurisdiction']]['version']\n                .($license['jurisdiction'] == 'international' ? '/' : '/'.$license['jurisdiction'].'/')\n                .'deed.en';\n    }\n\n    private function getAllowModificationId()\n    {\n        switch ($this->modification) {\n            case 'yes':\n                return 1;\n\n                break;\n            case 'yesbutshare':\n                return 10;\n\n                break;\n            case 'no':\n                return 0;\n\n                break;\n\n            default:\n                return 1;\n\n                break;\n        }\n    }\n\n    private function getAllowCommercialUseId()\n    {\n        return $this->commercial_use == 'no' ? '0' : '1';\n    }\n\n    private function getURLSlug($allow_modifications, $allow_commercial_use)\n    {\n        switch ($allow_modifications) {\n            case 'yes':\n                $modification_url_slug = '';\n\n                break;\n            case 'yesbutshare':\n                $modification_url_slug = '-sa';\n\n                break;\n            case 'no':\n                $modification_url_slug = '-nd';\n\n                break;\n        }\n        switch ($allow_commercial_use) {\n            case 'yes':\n                $commercial_use_url_slug = '';\n\n                break;\n            case 'no':\n                $commercial_use_url_slug = '-nc';\n\n                break;\n        }\n\n        return [\n            'allow_modifications' => $modification_url_slug,\n            'allow_commercial_use' => $commercial_use_url_slug,\n        ];\n    }\n\n    private static function get_modification_state($parameter_string)\n    {\n        if (strpos($parameter_string, 'sa')) {\n            return 'yesbutshare';\n        }\n\n        if (strpos($parameter_string, 'nd')) {\n            return 'no';\n        }\n\n        return 'yes';\n    }\n}\n"
  },
  {
    "path": "lib/model/media_file.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nuse Podlove\\Log;\n\nclass MediaFile extends Base\n{\n    use KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    /**\n     * Fetches file size if necessary.\n     *\n     * @override Base::save()\n     *\n     * @param mixed $determine_size\n     */\n    public function save($determine_size = true)\n    {\n        if ($determine_size && !$this->size) {\n            $this->determine_file_size();\n        }\n\n        return parent::save();\n    }\n\n    /**\n     * Find the related show model.\n     *\n     * @return null|\\Podlove\\Model\\EpisodeAsset\n     */\n    public function episode_asset()\n    {\n        return $this->with_blog_scope(function () {\n            return EpisodeAsset::find_by_id($this->episode_asset_id);\n        });\n    }\n\n    /**\n     * Find one downloadable example file.\n     *\n     * - JOIN episode to avoid dead media files\n     * - ORDER BY e.id DESC, mf.id ASC: get a recent episode and the first asset\n     */\n    public static function find_example()\n    {\n        $episode = Episode::latest();\n\n        if (!$episode) {\n            return;\n        }\n\n        $files = $episode->media_files();\n\n        $files = array_filter($files, function ($file) {\n            $asset = $file->episode_asset();\n\n            if (!$asset) {\n                return false;\n            }\n\n            $file_type = $asset->file_type();\n\n            if (!$file_type) {\n                return false;\n            }\n\n            return $asset->downloadable && $file_type->type == 'audio';\n        });\n\n        return reset($files);\n    }\n\n    public static function find_or_create_by_episode_id_and_episode_asset_id($episode_id, $episode_asset_id)\n    {\n        if (!$file = self::find_any_by_episode_id_and_episode_asset_id($episode_id, $episode_asset_id)) {\n            $file = new MediaFile();\n            $file->episode_id = $episode_id;\n            $file->episode_asset_id = $episode_asset_id;\n            $file->active = true;\n            $file->save();\n        }\n\n        return $file;\n    }\n\n    /**\n     * Finds an active media file for given episode and asset.\n     *\n     * TODO: Maybe rename to include the `active` condition in the function name.\n     *\n     * @param mixed $episode_id\n     * @param mixed $episode_asset_id\n     */\n    public static function find_by_episode_id_and_episode_asset_id($episode_id, $episode_asset_id)\n    {\n        $where = sprintf(\n            'episode_id = \"%s\" AND episode_asset_id = \"%s\" AND active = 1',\n            $episode_id,\n            $episode_asset_id\n        );\n\n        return MediaFile::find_one_by_where($where);\n    }\n\n    /**\n     * Finds a media file for given episode and asset, no matter if it is active or not.\n     *\n     * @param mixed $episode_id\n     * @param mixed $episode_asset_id\n     */\n    public static function find_any_by_episode_id_and_episode_asset_id($episode_id, $episode_asset_id)\n    {\n        $where = sprintf(\n            'episode_id = \"%s\" AND episode_asset_id = \"%s\"',\n            $episode_id,\n            $episode_asset_id\n        );\n\n        return MediaFile::find_one_by_where($where);\n    }\n\n    /**\n     * Is this media file valid?\n     *\n     * @return bool\n     */\n    public function is_valid()\n    {\n        return $this->size > 0;\n    }\n\n    /**\n     * Return public file URL.\n     *\n     * A source must be provided, an additional context is optional.\n     * Example sources: webplayer, download, feed, other\n     * Example contexts: home/episode/archive for player source, feed slug for feed source\n     *\n     * @param string      $source  download source\n     * @param null|string $context optional download context\n     *\n     * @return string\n     */\n    public function get_public_file_url($source, $context = null)\n    {\n        return $this->with_blog_scope(function () use ($source, $context) {\n            if (empty($source) && empty($context)) {\n                return $this->get_file_url();\n            }\n\n            $params = [\n                'source' => $source,\n                'context' => $context,\n            ];\n\n            $url = '';\n\n            switch ((string) \\Podlove\\get_setting('tracking', 'mode')) {\n                case 'ptm':\n                    // when PTM is active, add $source and $context but\n                    // keep the original file URL\n                    $url = $this->add_ptm_parameters(\n                        $this->get_file_url(),\n                        $params\n                    );\n\n                    break;\n                case 'ptm_analytics':\n                    // we track, so we need to generate a shadow URL\n                    if (get_option('permalink_structure')) {\n                        $path = '/podlove/file/'.$this->id;\n                        $path = $this->add_ptm_routing($path, $params);\n                    } else {\n                        $path = '?download_media_file='.$this->id;\n                        $path = $this->add_ptm_parameters($path, $params);\n                    }\n                    $url = home_url($path);\n\n                    break;\n\n                default:\n                    // tracking is off, return raw URL\n                    $url = $this->get_file_url();\n\n                    break;\n            }\n\n            return apply_filters('podlove_enclosure_url', $url);\n        });\n    }\n\n    public function add_ptm_routing($path, $params)\n    {\n        if (isset($params['source'])) {\n            $path .= \"/s/{$params['source']}\";\n        }\n\n        if (isset($params['context'])) {\n            $path .= \"/c/{$params['context']}\";\n        }\n\n        $path .= '/'.$this->urlencode_path_segments($this->get_download_file_name());\n\n        return $path;\n    }\n\n    public function add_ptm_parameters($path, $params)\n    {\n        // trim params\n        $params = array_map(function ($p) {\n            return trim((string) $p);\n        }, $params);\n\n        $connector = function ($path) {\n            return strpos($path, '?') === false ? '?' : '&';\n        };\n\n        // add params to path\n        foreach ($params as $param_name => $value) {\n            $path .= $connector($path).'ptm_'.$param_name.'='.$this->urlencode_path_segments($value);\n        }\n\n        // at last, add file param, so wget users get the right extension\n        $path .= $connector($path).'ptm_file='.$this->urlencode_path_segments($this->get_download_file_name());\n\n        return $path;\n    }\n\n    /**\n     * Return real file URL.\n     *\n     * For public facing URLs, use ::get_public_file_url().\n     *\n     * @return string\n     */\n    public function get_file_url()\n    {\n        return $this->with_blog_scope(function () {\n            $podcast = Podcast::get();\n\n            $episode = $this->episode();\n            $episode_asset = EpisodeAsset::find_by_id($this->episode_asset_id);\n            $file_type = FileType::find_by_id($episode_asset->file_type_id);\n\n            if (!$episode_asset || !$file_type || !$episode) {\n                return '';\n            }\n\n            $template = (string) $podcast->get_url_template();\n            $template = apply_filters('podlove_file_url_template', $template);\n            $template = str_replace('%media_file_base_url%', $podcast->get_media_file_base_uri(), $template);\n            $template = str_replace('%episode_slug%', \\Podlove\\prepare_episode_slug_for_url($episode->slug), $template);\n            $template = str_replace('%suffix%', $episode_asset->suffix ?? '', $template);\n            $template = str_replace('%format_extension%', $file_type->extension, $template);\n\n            return trim($template);\n        });\n    }\n\n    public function episode()\n    {\n        return $this->with_blog_scope(function () {\n            return Episode::find_by_id($this->episode_id);\n        });\n    }\n\n    public function get_file_name()\n    {\n        $asset = $this->episode_asset();\n        $suffix = $asset->suffix ?? '';\n        $extension = $asset->file_type()->extension;\n\n        return $this->episode()->slug.$suffix.'.'.$extension;\n    }\n\n    /**\n     * Build file name as it appears when you download the file.\n     *\n     * @return string\n     */\n    public function get_download_file_name()\n    {\n        $file_name = $this->get_file_name();\n\n        return apply_filters('podlove_download_file_name', $file_name, $this);\n    }\n\n    /**\n     * Determine file size by reading the HTTP Header of the file url.\n     */\n    public function determine_file_size()\n    {\n        $header = $this->curl_get_header();\n\n        $http_code = (int) $header['http_code'];\n        // do not change the filesize if http_code = 0\n        // aka \"an error occured I don't know how to deal with\" (probably timeout)\n        // => change to proper handling once \"Conflicts\" are introduced\n        if (podlove_is_resolved_and_reachable_http_status($http_code) && $http_code !== 304) {\n            if (isset($header['download_content_length']) && $header['download_content_length'] > 0) {\n                $this->size = $header['download_content_length'];\n            } else {\n                // We know that the file exists but have no way of determining its size.\n                // Having a proper state would be nice, but this \"size = 1 byte\" hack works for now.\n                $this->size = 1;\n            }\n        } elseif ($http_code >= 400) {\n            $this->size = 0;\n        }\n\n        if ($this->size <= 0) {\n            $this->etag = null;\n        }\n\n        return $header;\n    }\n\n    /**\n     * Retrieve header data via curl.\n     *\n     * @return array\n     */\n    public function curl_get_header()\n    {\n        $response = self::curl_get_header_for_url($this->get_file_url(), $this->etag);\n        $this->validate_request($response);\n\n        return $response['header'];\n    }\n\n    /**\n     * @todo  use \\Podlove\\Http\\Curl\n     *\n     * @param mixed      $url\n     * @param null|mixed $etag\n     *\n     * @return array\n     */\n    public static function curl_get_header_for_url($url, $etag = null)\n    {\n        if (!function_exists('curl_exec')) {\n            return [];\n        }\n\n        $curl = curl_init();\n\n        if (\\Podlove\\Http\\Curl::curl_can_follow_redirects()) {\n            curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); // follow redirects\n            curl_setopt($curl, CURLOPT_MAXREDIRS, 5);         // maximum number of redirects\n        } else {\n            $url = \\Podlove\\Http\\Curl::resolve_redirects($url, 5);\n        }\n\n        curl_setopt($curl, CURLOPT_URL, $url);\n        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); // make curl_exec() return the result\n        curl_setopt($curl, CURLOPT_HEADER, true);         // header only\n        curl_setopt($curl, CURLOPT_NOBODY, true);         // return no body; HTTP request method: HEAD\n        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, \\Podlove\\get_setting('website', 'ssl_verify_peer') == 'on'); // Don't check SSL certificate in order to be able to use self signed certificates\n        curl_setopt($curl, CURLOPT_FAILONERROR, true);\n        curl_setopt($curl, CURLOPT_TIMEOUT, 3);          // HEAD requests shouldn't take > 2 seconds\n\n        if ($etag) {\n            curl_setopt($curl, CURLOPT_HTTPHEADER, [\n                'If-None-Match: \"'.$etag.'\"',\n            ]);\n        }\n\n        curl_setopt($curl, CURLOPT_USERAGENT, \\Podlove\\Http\\Curl::user_agent());\n\n        $response = curl_exec($curl);\n        $response_header = curl_getinfo($curl);\n        $error = curl_error($curl);\n        curl_close($curl);\n\n        return [\n            'header' => $response_header,\n            'response' => $response,\n            'error' => $error,\n        ];\n    }\n\n    /**\n     * Validate media file headers.\n     *\n     * @todo  $this->id not available for first validation before media_file has been saved\n     *\n     * @param array $response curl response\n     */\n    private function validate_request($response)\n    {\n        // skip unsaved media files\n        if (!$this->id) {\n            return;\n        }\n\n        $header = $response['header'];\n\n        if ($response['error']) {\n            Log::get()->addError(\n                'Curl Error: '.$response['error'],\n                [\n                    'media_file_id' => $this->id,\n                    'header' => $header\n                ]\n            );\n        }\n\n        // skip validation if ETag did not change\n        if ((int) $header['http_code'] === 304) {\n            return;\n        }\n\n        // look for ETag and safe for later\n        if (podlove_is_resolved_and_reachable_http_status($header['http_code']) && preg_match('/ETag:\\s*\"([^\"]+)\"/i', $response['response'], $matches)) {\n            $this->etag = $matches[1];\n        } else {\n            $this->etag = null;\n        }\n\n        do_action('podlove_media_file_content_has_changed', $this->id);\n\n        // verify HTTP header\n        if (!preg_match('/^[23]\\d\\d$/', $header['http_code'])) {\n            Log::get()->addError(\n                'Unexpected http response when trying to access remote media file.',\n                ['media_file_id' => $this->id, 'http_code' => $header['http_code']]\n            );\n\n            return;\n        }\n\n        // check that content length exists and hasn't changed\n        if (!isset($header['download_content_length']) || $header['download_content_length'] <= 0) {\n            $mime_type = $this->episode_asset()->file_type()->mime_type;\n            Log::get()->addWarning(\n                'Unable to read \"Content-Length\" header. Impossible to determine file size.',\n                ['media_file_id' => $this->id, 'mime_type' => $header['content_type'], 'expected_mime_type' => $mime_type]\n            );\n        } elseif ($header['download_content_length'] != $this->size) {\n            Log::get()->addInfo(\n                'Change of media file content length detected.',\n                ['media_file_id' => $this->id, 'old_size' => $this->size, 'new_size' => $header['download_content_length']]\n            );\n        }\n\n        // check if mime type matches asset mime type\n        $mime_type = $this->episode_asset()->file_type()->mime_type;\n        if ($header['content_type'] != $mime_type) {\n            Log::get()->addWarning(\n                'Media file mime type does not match expected asset mime type.',\n                ['media_file_id' => $this->id, 'mime_type' => $header['content_type'], 'expected_mime_type' => $mime_type]\n            );\n        }\n    }\n\n    /**\n     * urlencode all segments of a path.\n     *\n     * We need to respect that slugs are allowed to contain slashes. That's why\n     * we need to urlencode the path segments instead of the whole path.\n     *\n     * @param mixed $path\n     */\n    private function urlencode_path_segments($path)\n    {\n        if (empty($path)) {\n            return '';\n        }\n\n        $parts = explode('/', $path);\n        $encoded = array_map('urlencode', $parts);\n\n        return implode('/', $encoded);\n    }\n}\n\nMediaFile::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nMediaFile::property('episode_id', 'INT');\nMediaFile::property('episode_asset_id', 'INT');\nMediaFile::property('size', 'INT');\nMediaFile::property('etag', 'VARCHAR(255)');\nMediaFile::property('active', 'TINYINT');\n"
  },
  {
    "path": "lib/model/network_trait.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\ntrait NetworkTrait\n{\n    /**\n     * Enables or disables network scope.\n     */\n    public static $is_network = false;\n\n    public static function table_name()\n    {\n        global $wpdb;\n\n        if (static::$is_network) {\n            return $wpdb->base_prefix.'global_'.parent::name();\n        }\n\n        return parent::table_name();\n    }\n\n    /**\n     * Activate network scope.\n     *\n     * @todo  move into a trait\n     */\n    public static function activate_network_scope()\n    {\n        static::$is_network = true;\n    }\n\n    /**\n     * Deactivate network scope.\n     */\n    public static function deactivate_network_scope()\n    {\n        static::$is_network = false;\n    }\n\n    /**\n     * Execute a callback within network scope.\n     *\n     * Example:\n     *\n     * \t\t$templates = Template::with_network_scope(function(){\n     *\t\t\treturn Template::all();\n     *\t\t});\n     *\n     * @param callable $callback\n     *\n     * @return mixed returns result of evaluated callback\n     */\n    public static function with_network_scope($callback)\n    {\n        if (!is_callable($callback)) {\n            throw new \\InvalidArgumentException('expected argument 1 of ::with_network_scope to be callable');\n        }\n\n        self::activate_network_scope();\n        $result = $callback();\n        self::deactivate_network_scope();\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "lib/model/podcast.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Simplified model for podcast data.\n */\nclass Podcast implements Licensable\n{\n    use KeepsBlogReferenceTrait;\n\n    /**\n     * Contains property names.\n     *\n     * @var array\n     */\n    protected static $properties = [];\n\n    /**\n     * Contains property values.\n     *\n     * @var array\n     */\n    private $data = [];\n\n    protected function __construct($blog_id)\n    {\n        $this->set_blog_id($blog_id);\n        $this->fetch();\n    }\n\n    public function __clone() {}\n\n    public function __set($name, $value)\n    {\n        if ($this->has_property($name)) {\n            $this->set_property($name, $value);\n        } else {\n            $this->{$name} = $value;\n        }\n    }\n\n    public function __get($name)\n    {\n        if ($this->has_property($name)) {\n            return $this->get_property($name);\n        }\n\n        return $this->{$name};\n    }\n\n    public static function get($blog_id = null)\n    {\n        return new self($blog_id);\n    }\n\n    public static function name()\n    {\n        return 'podcast';\n    }\n\n    /**\n     * Does the given property exist?\n     *\n     * @param string $name name of the property to test\n     *\n     * @return bool true if the property exists, else false\n     */\n    public function has_property($name)\n    {\n        return in_array($name, $this->property_names());\n    }\n\n    /**\n     * Return a list of property names.\n     *\n     * @return array property names\n     */\n    public function property_names()\n    {\n        return array_map(function ($p) {\n            return $p['name'];\n        }, self::$properties);\n    }\n\n    /**\n     * Define a property with by name.\n     *\n     * @param string $name Name of the property / column\n     */\n    public static function property($name)\n    {\n        if (!isset(self::$properties)) {\n            self::$properties = [];\n        }\n\n        array_push(self::$properties, ['name' => $name]);\n    }\n\n    /**\n     * Save current state to database.\n     */\n    public function save()\n    {\n        $this->with_blog_scope(function () {\n            update_option('podlove_podcast', $this->data);\n\n            do_action('podlove_model_save', $this);\n            do_action('podlove_model_change', $this);\n        });\n    }\n\n    /**\n     * Generate a human readable title.\n     *\n     * Return name and, if available, the subtitle. Separated by a dash.\n     *\n     * @return string\n     */\n    public function full_title()\n    {\n        $t = $this->title;\n\n        if ($this->subtitle) {\n            $t = $t.' - '.$this->subtitle;\n        }\n\n        return $t;\n    }\n\n    public function get_license()\n    {\n        return new License('podcast', [\n            'license_name' => $this->license_name,\n            'license_url' => $this->license_url,\n        ]);\n    }\n\n    public function get_license_picture_url()\n    {\n        return $this->get_license()->getPictureUrl();\n    }\n\n    public function get_license_html()\n    {\n        return $this->get_license()->getHtml();\n    }\n\n    public function get_url_template()\n    {\n        return $this->with_blog_scope(function () {\n            return \\Podlove\\get_setting('website', 'url_template');\n        });\n    }\n\n    public function get_feed_episode_title_variant()\n    {\n        if ($this->feed_episode_title_variant) {\n            return $this->feed_episode_title_variant;\n        }\n\n        return 'blog';\n    }\n\n    public function get_feed_episode_title_template()\n    {\n        if ($this->feed_episode_title_template) {\n            return $this->feed_episode_title_template;\n        }\n\n        return '%mnemonic%%episode_number% %episode_title%';\n    }\n\n    public function get_media_file_base_uri()\n    {\n        $base_uri = $this->media_file_base_uri;\n\n        // Avoid passing null/empty values into trailingslashit(), which can\n        // trigger deprecation notices and also return \"/\" for empty strings.\n        if (!is_string($base_uri) || $base_uri === '') {\n            return apply_filters('podlove_media_file_base_uri', '');\n        }\n\n        return apply_filters('podlove_media_file_base_uri', trailingslashit($base_uri));\n    }\n\n    /**\n     * Fetch all valid feeds.\n     *\n     * A feed is valid if...\n     *\n     * - it has an asset assigned (and the asset has a filetype assigned)\n     * - the slug in not empty\n     *\n     * @param mixed $args\n     *\n     * @return array list of feeds\n     */\n    public function feeds($args = [])\n    {\n        return $this->with_blog_scope(function () use ($args) {\n            $discoverable_condition = '';\n            if (isset($args['only_discoverable']) && $args['only_discoverable']) {\n                $discoverable_condition = ' AND f.discoverable';\n            }\n\n            $sql = '\n\t\t\t\tSELECT\n\t\t\t\t\tf.*\n\t\t\t\tFROM\n\t\t\t\t\t'.Feed::table_name().' f\n\t\t\t\t\tJOIN '.EpisodeAsset::table_name().' a ON a.id = f.episode_asset_id\n\t\t\t\t\tJOIN '.FileType::table_name().\" ft ON ft.id = a.file_type_id\n\t\t\t\tWHERE\n\t\t\t\t\tf.slug IS NOT NULL {$discoverable_condition}\n\t\t\t\tORDER BY\n\t\t\t\t\tposition ASC\n\t\t\t\";\n\n            return Feed::find_all_by_sql($sql);\n        });\n    }\n\n    public function landing_page_url()\n    {\n        return $this->with_blog_scope(function () {\n            return \\Podlove\\get_landing_page_url();\n        });\n    }\n\n    public function cover_art()\n    {\n        return new Image($this->cover_image, $this->title);\n    }\n\n    public function has_cover_art()\n    {\n        return strlen(trim($this->cover_image ?? '')) > 0;\n    }\n\n    public function default_copyright_claim()\n    {\n        return '© '.date('Y').' '.($this->author_name ?? $this->title);\n    }\n\n    public function explicit_text()\n    {\n        // backwards compatibility\n        if ($this->explicit == 2) {\n            return 'false';\n        }\n\n        return $this->explicit ? 'true' : 'false';\n    }\n\n    /**\n     * Episodes.\n     *\n     * Filter and order episodes with parameters:\n     *\n     * - post_id: one episode matching the given post id\n     * - post_ids: list of episodes matching the given list of post ids\n     * - category: list of episodes matching the category slug\n     * - show: list of episodes matching the show slug\n     * - slug: one episode matching the given slug\n     * - slugs: list of episodes matching the given list of slugs\n     * - post_status: Publication status of the post. Defaults to 'publish'\n     * - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'.\n     *   - 'publicationDate' - Order by publication date.\n     *   - 'recordingDate' - Order by recording date.\n     *   - 'title' - Order by title.\n     *   - 'slug' - Order by episode slug.\n     *     - 'limit' - Limit the number of returned episodes.\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        return $this->with_blog_scope(function () use ($args) {\n            global $wpdb;\n\n            // fetch single episodes\n            if (isset($args['post_id'])) {\n                return Episode::find_one_by_post_id($args['post_id']);\n            }\n\n            if (isset($args['slug'])) {\n                return Episode::find_one_by_slug($args['slug']);\n            }\n\n            // eager load posts, which fills WP object cache, avoiding n+1 performance issues\n            $posts = get_posts([\n                'post_type' => 'podcast',\n                'posts_per_page' => '-1',\n            ]);\n\n            // build conditions\n            $where = '1 = 1';\n            $joins = '';\n            if (isset($args['post_ids'])) {\n                $ids = array_filter( // remove \"0\"-IDs\n                    array_map( // convert elements to integers\n                        function ($n) {\n                            return (int) trim($n);\n                        },\n                        $args['post_ids']\n                    )\n                );\n\n                if (count($ids)) {\n                    $where .= ' AND p.ID IN ('.implode(',', $ids).')';\n                }\n            }\n\n            if (isset($args['slugs'])) {\n                $slugs = array_filter( // remove empty slugs\n                    array_map( // trim\n                        function ($n) {\n                            return \"'\".trim($n).\"'\";\n                        },\n                        $args['slugs']\n                    )\n                );\n\n                if (count($slugs)) {\n                    $where .= ' AND e.slug IN ('.implode(',', $slugs).')';\n                }\n            }\n\n            if (isset($args['post_status']) && is_array($args['post_status'])) {\n                $ins = [];\n                foreach ($args['post_status'] as $status) {\n                    $ins[] = '\"'.$status.'\"';\n                }\n                $where .= ' AND p.post_status IN ('.implode(',', $ins).')';\n            } elseif (isset($args['post_status']) && in_array($args['post_status'], get_post_stati())) {\n                $where .= \" AND p.post_status = '\".$args['post_status'].\"'\";\n            } else {\n                $where .= \" AND p.post_status = 'publish'\";\n            }\n\n            if (isset($args['category']) && strlen($args['category'])) {\n                $joins .= '\n\t\t\t\t\tJOIN '.$wpdb->term_relationships.' tr ON p.ID = tr.object_id\n\t\t\t\t\tJOIN '.$wpdb->term_taxonomy.' tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = \"category\"\n\t\t\t\t\tJOIN '.$wpdb->terms.' t ON t.term_id = tt.term_id AND t.slug = '.$wpdb->prepare('%s', $args['category']).'\n\t\t\t\t';\n            }\n\n            if (isset($args['show']) && strlen($args['show'])) {\n                $joins .= '\n\t\t\t\t\tJOIN '.$wpdb->term_relationships.' tr_show ON p.ID = tr_show.object_id\n\t\t\t\t\tJOIN '.$wpdb->term_taxonomy.' tt_show ON tt_show.term_taxonomy_id = tr_show.term_taxonomy_id AND tt_show.taxonomy = \"shows\"\n\t\t\t\t\tJOIN '.$wpdb->terms.' t_show ON t_show.term_id = tt_show.term_id AND t_show.slug = '.$wpdb->prepare('%s', $args['show']).'\n\t\t\t\t';\n            }\n\n            // order\n            $order_map = [\n                'publicationDate' => 'p.post_date',\n                'recordingDate' => 'e.recording_date',\n                'slug' => 'e.slug',\n                'title' => 'p.post_title',\n            ];\n\n            if (isset($args['orderby'], $order_map[$args['orderby']])) {\n                $orderby = $order_map[$args['orderby']];\n            } else {\n                $orderby = $order_map['publicationDate'];\n            }\n\n            if (isset($args['order'])) {\n                $args['order'] = strtoupper($args['order']);\n                if (in_array($args['order'], ['ASC', 'DESC'])) {\n                    $order = $args['order'];\n                } else {\n                    $order = 'DESC';\n                }\n            } else {\n                $order = 'DESC';\n            }\n\n            if (isset($args['limit'])) {\n                $limit = ' LIMIT '.(int) $args['limit'];\n            } else {\n                $limit = '';\n            }\n\n            $sql = '\n\t\t\t\tSELECT\n\t\t\t\t  e.*\n\t\t\t\tFROM\n\t\t\t\t\t'.Episode::table_name().' e\n\t\t\t\t\tINNER JOIN '.$wpdb->posts.' p ON e.post_id = p.ID\n\t\t\t\t\t'.$joins.'\n\t\t\t\tWHERE\n\t\t\t\t\t'.$where.'\n\t\t\t\t\tAND p.post_type = \"podcast\"\n\t\t\t\tORDER BY '.$orderby.' '.$order\n                .$limit;\n\n            $rows = $wpdb->get_results($sql);\n\n            if (!$rows) {\n                return [];\n            }\n\n            $episodes = [];\n            foreach ($rows as $row) {\n                $episode = new Episode();\n                $episode->flag_as_not_new();\n                foreach ($row as $property => $value) {\n                    $episode->{$property} = $value;\n                }\n                $episodes[] = $episode;\n            }\n\n            // filter out invalid episodes\n            return array_filter($episodes, function ($e) {\n                return $e->is_valid();\n            });\n        });\n    }\n\n    private function set_property($name, $value)\n    {\n        $this->data[$name] = $value;\n    }\n\n    private function get_property($name)\n    {\n        if (isset($this->data[$name])) {\n            return $this->data[$name];\n        }\n\n        return null;\n    }\n\n    /**\n     * Return a list of property dictionaries.\n     *\n     * @return array property list\n     */\n    private function properties()\n    {\n        if (!isset(self::$properties)) {\n            self::$properties = [];\n        }\n\n        return self::$properties;\n    }\n\n    /**\n     * Load podcast data.\n     */\n    private function fetch()\n    {\n        $this->data = $this->with_blog_scope(function () {\n            return get_option('podlove_podcast', []);\n        });\n    }\n}\n\nPodcast::property('title');\nPodcast::property('subtitle');\nPodcast::property('itunes_type');\nPodcast::property('cover_image');\nPodcast::property('summary');\nPodcast::property('mnemonic');\nPodcast::property('author_name');\nPodcast::property('owner_name');\nPodcast::property('owner_email');\nPodcast::property('publisher_name');\nPodcast::property('publisher_url');\nPodcast::property('license_type');\nPodcast::property('license_name');\nPodcast::property('license_url');\nPodcast::property('license_cc_allow_modifications');\nPodcast::property('license_cc_allow_commercial_use');\nPodcast::property('license_cc_license_jurisdiction');\nPodcast::property('category_1');\nPodcast::property('category_2');\nPodcast::property('category_3');\nPodcast::property('explicit');\nPodcast::property('label');\nPodcast::property('episode_prefix');\nPodcast::property('media_file_base_uri');\nPodcast::property('uri_delimiter');\nPodcast::property('limit_items');\nPodcast::property('feed_episode_title_variant');\nPodcast::property('feed_episode_title_template');\nPodcast::property('feed_transcripts');\nPodcast::property('language');\nPodcast::property('complete');\nPodcast::property('flattr'); // @deprecated since 2.3.0 (now: wp_option \"podlove_flattr\")\n// TODO: (Refactoring) manage PLUS options via REST API and store them somewhere else\nPodcast::property('plus_enable_proxy');\nPodcast::property('plus_enable_storage');\nPodcast::property('plus_slug');\nPodcast::property('funding_url');\nPodcast::property('funding_label');\nPodcast::property('copyright');\nPodcast::property('guid');\n"
  },
  {
    "path": "lib/model/template.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Episode Templates.\n */\nclass Template extends Base\n{\n    use NetworkTrait;\n\n    /**\n     * Returns all local + network templates.\n     *\n     * Local template override global templates with same title.\n     *\n     * @return array\n     */\n    public static function all_globally()\n    {\n        if (!is_multisite()) {\n            return Template::all();\n        }\n\n        $local = Template::all();\n        $global = Template::with_network_scope(function () {\n            return Template::all();\n        });\n\n        $all = [];\n\n        foreach ($global as $template) {\n            $all[$template->title] = $template;\n        }\n\n        foreach ($local as $template) {\n            $all[$template->title] = $template;\n        }\n\n        ksort($all);\n\n        return array_values($all);\n    }\n\n    public static function find_one_by_title_with_fallback($template_id)\n    {\n        if ($template = self::find_one_by_title($template_id)) {\n            return $template;\n        }\n\n        if (is_multisite()) {\n            return self::with_network_scope(function () use ($template_id) {\n                return self::find_one_by_title($template_id);\n            });\n        }\n\n        return null;\n    }\n}\n\nTemplate::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nTemplate::property('title', 'VARCHAR(255)');\nTemplate::property('content', 'TEXT');\n"
  },
  {
    "path": "lib/model/template_assignment.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\n/**\n * Simplified Singleton model for template assignment data.\n */\nclass TemplateAssignment\n{\n    /**\n     * Singleton instance container.\n     *\n     * @var null|\\Podlove\\Model\\AssetAssignment\n     */\n    private static $instance;\n\n    /**\n     * Contains property values.\n     *\n     * @var array\n     */\n    private $data = [];\n\n    /**\n     * Contains property names.\n     *\n     * @var array\n     */\n    private $properties;\n\n    protected function __construct()\n    {\n        $this->fetch();\n    }\n\n    private function __clone() {}\n\n    public function __set($name, $value)\n    {\n        if ($this->has_property($name)) {\n            $this->set_property($name, $value);\n        } else {\n            $this->{$name} = $value;\n        }\n    }\n\n    public function __get($name)\n    {\n        if ($this->has_property($name)) {\n            return $this->get_property($name);\n        }\n\n        return $this->{$name};\n    }\n\n    /**\n     * Singleton.\n     *\n     * @return \\Podlove\\Model\\AssetAssignment\n     */\n    public static function get_instance()\n    {\n        if (!isset(self::$instance)) {\n            self::$instance = new self();\n        }\n\n        return self::$instance;\n    }\n\n    public static function name()\n    {\n        return 'template_assignment';\n    }\n\n    /**\n     * Does the given property exist?\n     *\n     * @param string $name name of the property to test\n     *\n     * @return bool true if the property exists, else false\n     */\n    public function has_property($name)\n    {\n        return in_array($name, $this->property_names());\n    }\n\n    /**\n     * Return a list of property names.\n     *\n     * @return array property names\n     */\n    public function property_names()\n    {\n        return array_map(function ($p) {\n            return $p['name'];\n        }, $this->properties);\n    }\n\n    /**\n     * Define a property with by name.\n     *\n     * @param string $name Name of the property / column\n     */\n    public function property($name)\n    {\n        if (!isset($this->properties)) {\n            $this->properties = [];\n        }\n\n        array_push($this->properties, ['name' => $name]);\n    }\n\n    /**\n     * Save current state to database.\n     */\n    public function save()\n    {\n        update_option('podlove_template_assignment', $this->data);\n\n        do_action('podlove_model_save', $this);\n        do_action('podlove_model_change', $this);\n    }\n\n    /**\n     * Generate a human readable title.\n     *\n     * Return name and, if available, the subtitle. Separated by a dash.\n     *\n     * @return string\n     */\n    public function full_title()\n    {\n        $t = $this->title;\n\n        if ($this->subtitle) {\n            $t = $t.' - '.$this->subtitle;\n        }\n\n        return $t;\n    }\n\n    private function set_property($name, $value)\n    {\n        $this->data[$name] = $value;\n    }\n\n    private function get_property($name)\n    {\n        if (isset($this->data[$name])) {\n            return $this->data[$name];\n        }\n\n        return null;\n    }\n\n    /**\n     * Return a list of property dictionaries.\n     *\n     * @return array property list\n     */\n    private function properties()\n    {\n        if (!isset($this->properties)) {\n            $this->properties = [];\n        }\n\n        return $this->properties;\n    }\n\n    /**\n     * Load podcast data.\n     */\n    private function fetch()\n    {\n        $this->data = get_option('podlove_template_assignment', []);\n    }\n}\n\n$template_assignment = TemplateAssignment::get_instance();\n$template_assignment->property('top');\n$template_assignment->property('bottom');\n$template_assignment->property('head');\n$template_assignment->property('header');\n$template_assignment->property('footer');\n\n$template_assignment = apply_filters('podlove_model_template_assignment_schema', $template_assignment);\n"
  },
  {
    "path": "lib/model/user_agent.php",
    "content": "<?php\n\nnamespace Podlove\\Model;\n\nuse Podlove\\Jobs\\CronJobRunner;\nuse PodlovePublisher_Vendor\\DeviceDetector\\DeviceDetector;\n\nclass UserAgent extends Base\n{\n    /**\n     * Fetch new data for all UAs.\n     */\n    public static function reparse_all()\n    {\n        CronJobRunner::create_job('\\Podlove\\Jobs\\UserAgentRefreshJob');\n    }\n\n    /**\n     * Parse UA string and fill in other attributes.\n     */\n    public function parse()\n    {\n        // parse with opawg\n        $data_file = \\Podlove\\PLUGIN_DIR.'data/opawg.json';\n        $data_raw = file_get_contents($data_file);\n        $user_agent_data = json_decode($data_raw);\n        $user_agent_data = apply_filters('podlove_useragent_opawg_data', $user_agent_data);\n\n        if (!$user_agent_data) {\n            error_log('[Podlove Publisher] OPAWG data file is invalid JSON');\n\n            // fallback to DeviceDetector parser\n            return $this->parse_by_device_detector();\n        }\n\n        $user_agent_string = $this->user_agent;\n\n        $user_agent_match = array_reduce($user_agent_data, function ($agg, $item) use ($user_agent_string) {\n            if ($agg !== null) {\n                return $agg;\n            }\n\n            foreach ($item->user_agents as $regex) {\n                $compiled_regex = str_replace('/', '\\/', $regex);\n                if (preg_match(\"/{$compiled_regex}/\", $user_agent_string) === 1) {\n                    return $item;\n                }\n            }\n\n            return $agg;\n        }, null);\n\n        if ($user_agent_match) {\n            $this->client_name = isset($user_agent_match->app) ? $user_agent_match->app : '';\n            $this->os_name = isset($user_agent_match->os) ? self::normalizeOS($user_agent_match->os) : '';\n\n            if (isset($user_agent_match->bot) && $user_agent_match->bot) {\n                $this->bot = 1;\n            }\n\n            return $this;\n        }\n\n        // fallback to DeviceDetector parser\n        return $this->parse_by_device_detector();\n    }\n\n    public static function find_or_create_by_uastring($ua_string)\n    {\n        $ua_string = trim($ua_string);\n\n        if (!strlen($ua_string)) {\n            return null;\n        }\n\n        $agent = self::find_one_by_user_agent($ua_string);\n\n        if (!$agent) {\n            $agent = new self();\n            $agent->user_agent = $ua_string;\n            $agent->parse()->save();\n        }\n\n        return $agent;\n    }\n\n    public static function normalizeOS($os_name)\n    {\n        $map = [\n            'ios' => 'iOS',\n            'android' => 'Android',\n            'mac' => 'macOS',\n            'macos' => 'macOS',\n            'watchos' => 'watchOS',\n            'windows' => 'Windows',\n            'linux' => 'Linux',\n            'sonos' => 'Sonos',\n            'homepod_os' => 'HomepodOS',\n            'tvos' => 'tvOS',\n        ];\n\n        return $map[trim(strtolower($os_name))] ?? $os_name;\n    }\n\n    private function parse_by_device_detector()\n    {\n        $dd = new DeviceDetector($this->user_agent);\n\n        // only return true if a bot was detected (speeds up detection a bit)\n        $dd->discardBotInformation();\n\n        $dd->parse();\n\n        if ($dd->isBot()) {\n            $this->bot = 1;\n        } else {\n            $client = $dd->getClient();\n\n            if ($this->counts_as_bot($client)) {\n                $this->bot = 1;\n\n                return $this;\n            }\n\n            if (isset($client['name'])) {\n                $this->client_name = $client['name'];\n            }\n\n            if (isset($client['version'])) {\n                $this->client_version = $client['version'];\n            }\n\n            if (isset($client['type'])) {\n                $this->client_type = $client['type'];\n            }\n\n            $os = $dd->getOs();\n\n            if (isset($os['name'])) {\n                $this->os_name = self::normalizeOS($os['name']);\n            }\n\n            if (isset($os['version'])) {\n                $this->os_version = $os['version'];\n            }\n\n            $this->device_brand = $dd->getBrand();\n            $this->device_model = $dd->getModel();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Classify some clients as bots.\n     *\n     * @param mixed $client\n     *\n     * @return bool\n     */\n    private function counts_as_bot($client)\n    {\n        $type = isset($client['type']) ? $client['type'] : '';\n        $name = isset($client['name']) ? $client['name'] : '';\n\n        if ($type == 'library' && $name == 'WWW::Mechanize') {\n            return true;\n        }\n\n        return false;\n    }\n}\n\nUserAgent::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nUserAgent::property('user_agent', 'TEXT', ['index' => true, 'index_length' => 400]);\n\nUserAgent::property('bot', 'TINYINT');\nUserAgent::property('client_name', 'VARCHAR(255)');\nUserAgent::property('client_version', 'VARCHAR(255)');\nUserAgent::property('client_type', 'VARCHAR(255)');\nUserAgent::property('os_name', 'VARCHAR(255)');\nUserAgent::property('os_version', 'VARCHAR(255)');\nUserAgent::property('device_brand', 'VARCHAR(255)');\nUserAgent::property('device_model', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/modules/affiliate/affiliate.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Affiliate;\n\nclass Affiliate extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Affiliate';\n    protected $module_description = 'Amazon etc. affiliate link features.';\n    protected $module_group = 'system';\n\n    public static function is_core()\n    {\n        return true;\n    }\n\n    // Was activated\n    public function was_activated($module_name = 'affiliate') {}\n\n    public function load()\n    {\n        add_action('podlove_podcast_settings_tabs', [__CLASS__, 'podcast_settings_tabs']);\n    }\n\n    public static function podcast_settings_tabs($tabs)\n    {\n        $tabs->addTab(new PodcastAffiliateSettingsTab('affiliate', __('Affiliate', 'podlove-podcasting-plugin-for-wordpress')));\n\n        return $tabs;\n    }\n\n    public static function get_tracking_id($site)\n    {\n        return get_option('podlove_affiliate', [])[$site] ?? null;\n    }\n\n    public static function apply_amazon_de_affiliate($url)\n    {\n        $tracking_id = self::get_tracking_id('amazon_de');\n\n        if (!$tracking_id) {\n            return;\n        }\n\n        return add_query_arg('tag', $tracking_id, $url);\n    }\n\n    public static function apply_thomann_de_affiliate($url)\n    {\n        $tracking_id = self::get_tracking_id('thomann_de');\n\n        if (!$tracking_id) {\n            return;\n        }\n\n        return add_query_arg('partner_id', $tracking_id, $url);\n    }\n}\n"
  },
  {
    "path": "lib/modules/affiliate/podcast_affiliate_settings_tab.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Affiliate;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass PodcastAffiliateSettingsTab extends Tab\n{\n    private static $nonce = 'update_podcast_settings_affiliate';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_affiliate']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $settings = self::get_setting();\n\n        foreach ($_POST['podlove_affiliate'] as $key => $value) {\n            $settings[$key] = $value;\n        }\n\n        update_option('podlove_affiliate', $settings);\n\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $form_attributes = [\n            'context' => 'podlove_affiliate',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ]; ?>\n    <p>\n      <?php echo __('Register your Affiliate IDs', 'podlove-podcasting-plugin-for-wordpress'); ?>\n    </p>\n<?php\n\n    \\Podlove\\Form\\build_for((object) self::get_setting(), $form_attributes, function ($form) {\n        $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n        $wrapper->string('amazon_de', [\n            'label' => __('amazon.de', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Your amazon.de tracking id.', 'podlove-podcasting-plugin-for-wordpress'),\n            'html' => ['class' => 'regular-text podlove-check-input'],\n        ]);\n\n        $wrapper->string('thomann_de', [\n            'label' => __('thomann.de', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Your thomann.de partner id.', 'podlove-podcasting-plugin-for-wordpress'),\n            'html' => ['class' => 'regular-text podlove-check-input'],\n        ]);\n    });\n    }\n\n    public static function get_setting()\n    {\n        return get_option('podlove_affiliate');\n    }\n}\n"
  },
  {
    "path": "lib/modules/analytics_heartbeat/analytics_heartbeat.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\AnalyticsHeartbeat;\n\nuse Podlove\\Modules\\AnalyticsHeartbeat\\Model\\Heartbeat;\n\nclass Analytics_Heartbeat extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Analytics Heartbeat';\n    protected $module_description = 'Keeps track of when Analytics are active or inactive.';\n    protected $module_group = 'system';\n\n    public static function is_core()\n    {\n        return true;\n    }\n\n    public function load()\n    {\n        // add_action('podlove_module_was_activated_analytics_heartbeat', [$this, 'was_activated']);\n        add_action('podlove_analytics_heartbeat', [$this, 'check_analytics_status']);\n        $this->schedule_crons();\n    }\n\n    // public function was_activated($module_name) {\n    // }\n\n    public function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_analytics_heartbeat')) {\n            wp_schedule_event(time(), 'hourly', 'podlove_analytics_heartbeat');\n        }\n    }\n\n    public function uninstall()\n    {\n        Heartbeat::destroy();\n    }\n\n    public function check_analytics_status()\n    {\n        Heartbeat::build();\n\n        $current_status = \\Podlove\\get_setting('tracking', 'mode');\n        $last_beat = Heartbeat::last();\n\n        if (!$last_beat || $current_status != $last_beat->status) {\n            $heartbeat = new Heartbeat();\n            $heartbeat->status_start = date('Y-m-d H:i:s');\n            $heartbeat->status_end = date('Y-m-d H:i:s');\n            $heartbeat->status = $current_status;\n            $heartbeat->beats = 1;\n            $heartbeat->save();\n        } else {\n            ++$last_beat->beats;\n            $last_beat->status_end = date('Y-m-d H:i:s');\n            $last_beat->save();\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/analytics_heartbeat/model/heartbeat.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\AnalyticsHeartbeat\\Model;\n\nuse Podlove\\Model\\Base;\n\nclass Heartbeat extends Base {}\n\nHeartbeat::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nHeartbeat::property('status_start', 'DATETIME');\nHeartbeat::property('status_end', 'DATETIME');\nHeartbeat::property('status', 'VARCHAR(255)');\nHeartbeat::property('beats', 'INT UNSIGNED');\n"
  },
  {
    "path": "lib/modules/asset_validation/asset_validation.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\AssetValidation;\n\nuse Podlove\\Log;\nuse Podlove\\Model;\n\nclass Asset_Validation extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Asset Validation';\n    protected $module_description = 'Automatically validate assets once in a while. Fresh posts will be validated more often than old posts.';\n    protected $module_group = 'system';\n\n    public function load()\n    {\n        add_action('podlove_module_was_activated_asset_validation', [$this, 'was_activated']);\n        add_action('podlove_module_was_deactivated_asset_validation', [$this, 'was_deactivated']);\n        add_action('podlove_asset_validation', [$this, 'do_valiations']);\n        add_action('podlove_module_before_settings_asset_validation', function () {\n            $this->schedule_crons();\n\n            if ($timezone = get_option('timezone_string')) {\n                date_default_timezone_set($timezone);\n            } ?>\n\t\t\t<div>\n\t\t\t\t<em>\n\t\t\t\t\t<?php\n                    echo sprintf(\n                        __('Next scheduled validation: %s'),\n                        date(get_option('date_format').' '.get_option('time_format'), wp_next_scheduled('podlove_asset_validation'))\n                    ); ?>\n\t\t\t\t</em>\n\t\t\t</div>\n\t\t\t<?php\n        });\n    }\n\n    public function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_asset_validation')) {\n            wp_schedule_event(time(), 'hourly', 'podlove_asset_validation');\n        }\n    }\n\n    public function was_activated($module_name)\n    {\n        $this->schedule_crons();\n    }\n\n    public function was_deactivated($module_name)\n    {\n        wp_clear_scheduled_hook('podlove_asset_validation');\n    }\n\n    /**\n     * Main Cron function call.\n     */\n    public function do_valiations()\n    {\n        set_time_limit(1800); // set max_execution_time to half an hour\n\n        $new_posts_query = $this->get_new_posts_needing_validation();\n        while ($new_posts_query->have_posts()) {\n            $this->validate_post($new_posts_query->next_post());\n        }\n\n        $adolescent_posts_query = $this->get_adolescent_posts_needing_validation();\n        while ($adolescent_posts_query->have_posts()) {\n            $this->validate_post($adolescent_posts_query->next_post());\n        }\n\n        $aged_posts_query = $this->get_aged_posts_needing_validation();\n        while ($aged_posts_query->have_posts()) {\n            $this->validate_post($aged_posts_query->next_post());\n        }\n    }\n\n    private function validate_post(\\WP_Post $post)\n    {\n        $episode = Model\\Episode::find_or_create_by_post_id($post->ID);\n        if ($episode && $episode->is_valid()) {\n            // Log::get()->addInfo( 'Validate episode', array( 'episode_id' => $episode->id ) );\n            $episode->refetch_files();\n            update_post_meta($post->ID, '_podlove_last_validated_at', time());\n        }\n    }\n\n    /**\n     * Get posts of quite some age needing validation.\n     *\n     * - \"quite some age\" meaning older than 4 weeks\n     * - \"needing validation\" meaning \"not validated within 1 day\"\n     *\n     * @return WP_Query\n     */\n    private function get_aged_posts_needing_validation()\n    {\n        $age_filter = function ($where = '') {\n            $where .= \" AND post_date < '\".date('Y-m-d', strtotime('-4 weeks')).\"'\";\n\n            return $where;\n        };\n\n        add_filter('posts_where', $age_filter);\n        $query = new \\WP_Query([\n            'post_type' => 'podcast',\n            'posts_per_page' => -1,\n            'meta_query' => [\n                'relation' => 'OR',\n                [\n                    'key' => '_podlove_last_validated_at',\n                    'value' => 1, // nonsensical but required\n                    'compare' => 'NOT EXISTS',\n                ],\n                [\n                    'type' => 'NUMERIC',\n                    'key' => '_podlove_last_validated_at',\n                    'compare' => '<=',\n                    'value' => strtotime('-6 hours'),\n                ],\n            ],\n        ]);\n        remove_filter('posts_where', $age_filter);\n\n        return $query;\n    }\n\n    /**\n     * Get posts of intermediate age needing validation.\n     *\n     * - \"intermediate age\" meaning older than 24h but younger than 4 weeks\n     * - \"needing validation\" meaning \"not validated within 6 hours\"\n     *\n     * @return WP_Query\n     */\n    private function get_adolescent_posts_needing_validation()\n    {\n        $age_filter = function ($where = '') {\n            $where .= \" AND post_date BETWEEN '\".date('Y-m-d', strtotime('-4 weeks')).\"' AND '\".date('Y-m-d', strtotime('-1 day')).\"'\";\n\n            return $where;\n        };\n\n        add_filter('posts_where', $age_filter);\n        $query = new \\WP_Query([\n            'post_type' => 'podcast',\n            'posts_per_page' => -1,\n            'meta_query' => [\n                'relation' => 'OR',\n                [\n                    'key' => '_podlove_last_validated_at',\n                    'value' => 1, // nonsensical but required\n                    'compare' => 'NOT EXISTS',\n                ],\n                [\n                    'type' => 'NUMERIC',\n                    'key' => '_podlove_last_validated_at',\n                    'compare' => '<=',\n                    'value' => strtotime('-6 hours'),\n                ],\n            ],\n        ]);\n        remove_filter('posts_where', $age_filter);\n\n        return $query;\n    }\n\n    /**\n     * Get new posts needing validation.\n     *\n     * - \"new\" meaning \"published within last 24 hours\"\n     * - \"needing validation\" meaning \"not validated within last hour\"\n     *\n     * @return WP_Query\n     */\n    private function get_new_posts_needing_validation()\n    {\n        $age_filter = function ($where = '') {\n            $where .= \" AND post_date > '\".date('Y-m-d', strtotime('-1 day')).\"'\";\n\n            return $where;\n        };\n\n        add_filter('posts_where', $age_filter);\n        $query = new \\WP_Query([\n            'post_type' => 'podcast',\n            'posts_per_page' => -1,\n            'meta_query' => [\n                'relation' => 'OR',\n                [\n                    'key' => '_podlove_last_validated_at',\n                    'value' => 1, // nonsensical but required\n                    'compare' => 'NOT EXISTS',\n                ],\n                [\n                    'type' => 'NUMERIC',\n                    'key' => '_podlove_last_validated_at',\n                    'compare' => '<=',\n                    'value' => strtotime('-1 hour'),\n                ],\n            ],\n        ]);\n        remove_filter('posts_where', $age_filter);\n\n        return $query;\n    }\n}\n"
  },
  {
    "path": "lib/modules/auphonic/api_wrapper.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Auphonic;\n\nuse Podlove\\Http;\nuse Podlove\\Log;\n\nclass API_Wrapper\n{\n    public static $auth_key;\n    private $module;\n\n    public function __construct(Auphonic $module)\n    {\n        $this->module = $module;\n        self::$auth_key = $this->module->get_module_option('auphonic_api_key');\n    }\n\n    public function fetch_authorized_user()\n    {\n        return self::cache_for('podlove_auphonic_user', function () {\n            $curl = new Http\\Curl();\n            $curl->request('https://auphonic.com/api/user.json', [\n                'headers' => [\n                    'Content-type' => 'application/json',\n                    'Authorization' => 'Bearer '.API_Wrapper::$auth_key,\n                ],\n            ]);\n            $response = $curl->get_response();\n\n            if ($curl->isSuccessful()) {\n                $decoded_user = json_decode($response['body']);\n\n                if (!$decoded_user || !isset($decoded_user->data)) {\n                    Log::get()->addWarning('Auphonic user verification returned unexpected payload.', [\n                        'token_debug' => self::token_debug(self::$auth_key),\n                        'body' => is_array($response) && isset($response['body']) ? $response['body'] : null,\n                    ]);\n                }\n\n                return $decoded_user ? $decoded_user : false;\n            }\n\n            Log::get()->addWarning('Auphonic user verification failed.', [\n                'token_debug' => self::token_debug(self::$auth_key),\n                'response' => $response,\n            ]);\n\n            return false;\n        });\n    }\n\n    public function fetch_presets()\n    {\n        return self::cache_for('podlove_auphonic_presets', function () {\n            $curl = new Http\\Curl();\n            $curl->request('https://auphonic.com/api/presets.json', [\n                'headers' => [\n                    'Content-type' => 'application/json',\n                    'Authorization' => 'Bearer '.API_Wrapper::$auth_key,\n                ],\n            ]);\n            $response = $curl->get_response();\n\n            if ($curl->isSuccessful()) {\n                return json_decode($response['body']);\n            }\n\n            Log::get()->addWarning('Auphonic preset fetch failed.', [\n                'token_debug' => self::token_debug(self::$auth_key),\n                'response' => $response,\n            ]);\n\n            return [];\n        }, 10);\n    }\n\n    private static function token_debug($token)\n    {\n        if (!$token) {\n            return [\n                'present' => false,\n            ];\n        }\n\n        return [\n            'present' => true,\n            'length' => strlen($token),\n            'sha1_prefix' => substr(sha1($token), 0, 12),\n        ];\n    }\n\n    private static function cache_for($cache_key, $callback, $duration = 31536000 /* 1 year */)\n    {\n        if (($value = get_transient($cache_key)) !== false) {\n            return $value;\n        }\n        $value = call_user_func($callback);\n\n        if ($value !== false) {\n            set_transient($cache_key, $value, $duration);\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "lib/modules/auphonic/auphonic.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Auphonic;\n\nuse Podlove\\Http;\n\nclass Auphonic extends \\Podlove\\Modules\\Base\n{\n    private const OAUTH_CLIENT_ID = '517dfd1a3074f9cf551ef1bf81d681';\n    protected $module_name = 'Auphonic';\n    protected $module_description = 'Auphonic is an audio post production web service. This module adds an interface to episodes, so you can create and manage productions right from Podlove Publisher.';\n    protected $module_group = 'external services';\n\n    /**\n     * API to Auphonic Service.\n     *\n     * @var Podlove\\Modules\\Auphonic\\API_Wrapper\n     */\n    private $api;\n\n    /**\n     * Plus file transfer handler.\n     *\n     * @var Podlove\\Modules\\Auphonic\\PlusFileTransfer\n     */\n    private $plus_file_transfer;\n\n    public function load()\n    {\n        $this->api = new API_Wrapper($this);\n        $this->plus_file_transfer = new PlusFileTransfer($this);\n\n        new EpisodeEnhancer($this);\n\n        add_action('wp_ajax_podlove-add-production-for-auphonic-webhook', [$this, 'ajax_add_episode_for_auphonic_webhook']);\n        add_action('wp', [$this, 'auphonic_webhook']);\n\n        add_action('rest_api_init', [$this, 'api_init']);\n\n        add_action('wp_ajax_podlove-refresh-auphonic-presets', [$this, 'ajax_refresh_presets']);\n\n        add_action('podlove_show_form_end', [$this, 'shows_module_append_preset_option']);\n\n        add_filter('pre_update_option_'.$this->get_module_options_name(), [$this, 'intercept_settings_save'], 10, 2);\n        add_action('admin_notices', [$this, 'render_settings_errors']);\n\n        if (isset($_GET['page']) && $_GET['page'] == 'podlove_settings_modules_handle') {\n            add_action('admin_bar_init', [$this, 'check_code']);\n        }\n\n        add_action('init', [$this, 'register_settings']);\n    }\n\n    public function api_init()\n    {\n        $api_v2 = new REST_API($this);\n        $api_v2->register_routes();\n    }\n\n    public function register_settings()\n    {\n        if (!self::is_module_settings_page()) {\n            return;\n        }\n\n        $this->register_option('auphonic_api_key', 'hidden', [\n            'label' => __('Authentication', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => $this->get_authorization_description(),\n            'html' => ['class' => 'regular-text'],\n        ]);\n\n        $this->register_option('auphonic_manual_api_key', 'password', [\n            'label' => __('Or Use an API Key', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => sprintf(\n                '%s<br>%s',\n                __('Instead of OAuth, paste an Auphonic API key from your account settings. You only need one method. Saving a new key here replaces the current Auphonic connection after validation.', 'podlove-podcasting-plugin-for-wordpress'),\n                sprintf(\n                    '<a href=\"%s\" target=\"_blank\" rel=\"noopener noreferrer\">%s</a>',\n                    esc_url('https://auphonic.com/accounts/settings/'),\n                    __('Open Auphonic account settings', 'podlove-podcasting-plugin-for-wordpress')\n                )\n            ),\n            'html' => [\n                'class' => 'regular-text',\n                'autocomplete' => 'new-password',\n                'placeholder' => __('Paste API key to replace current token', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n        ]);\n    }\n\n    public function render_settings_errors()\n    {\n        if (!self::is_module_settings_page()) {\n            return;\n        }\n\n        settings_errors($this->get_module_options_name());\n    }\n\n    public function intercept_settings_save($new_value, $old_value)\n    {\n        if (!self::is_module_settings_page()) {\n            return $new_value;\n        }\n\n        if (!is_array($new_value)) {\n            $new_value = [];\n        }\n\n        if (!is_array($old_value)) {\n            $old_value = [];\n        }\n\n        $manual_api_key = '';\n\n        if (isset($new_value['auphonic_manual_api_key'])) {\n            $manual_api_key = trim(wp_unslash($new_value['auphonic_manual_api_key']));\n            unset($new_value['auphonic_manual_api_key']);\n        }\n\n        if ($manual_api_key === '') {\n            return $new_value;\n        }\n\n        if (!$this->validate_api_key($manual_api_key)) {\n            add_settings_error(\n                $this->get_module_options_name(),\n                'invalid_auphonic_api_key',\n                __('The Auphonic API key could not be verified. The existing connection was kept.', 'podlove-podcasting-plugin-for-wordpress'),\n                'error'\n            );\n\n            $new_value['auphonic_api_key'] = isset($old_value['auphonic_api_key']) ? $old_value['auphonic_api_key'] : '';\n\n            return $new_value;\n        }\n\n        $new_value['auphonic_api_key'] = $manual_api_key;\n        $this->clear_auphonic_cache();\n\n        add_settings_error(\n            $this->get_module_options_name(),\n            'valid_auphonic_api_key',\n            __('The Auphonic API key was saved successfully.', 'podlove-podcasting-plugin-for-wordpress'),\n            'updated'\n        );\n\n        return $new_value;\n    }\n\n    public function shows_module_append_preset_option($wrapper)\n    {\n        $preset_list = $this->get_presets_list();\n\n        $wrapper->select('auphonic_preset', [\n            'label' => __('Auphonic Preset', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Define a Auphonic Preset for this show. If none is set, the global module preset is used.', 'podlove-podcasting-plugin-for-wordpress'),\n            'type' => 'select',\n            'options' => $preset_list,\n        ]);\n    }\n\n    /**\n     * Register Event for Auphonic Webhook.\n     */\n    public function auphonic_webhook()\n    {\n        if (!isset($_REQUEST['podlove-auphonic-production']) || empty($_REQUEST['podlove-auphonic-production']) || empty($_POST)) {\n            return;\n        }\n\n        if ($_POST['status_string'] !== 'Done') {\n            \\Podlove\\Log::get()->addError(\n                'Auphonic webhook failed.',\n                ['data' => $_POST]\n            );\n\n            exit;\n        }\n\n        $post_id = (int) $_REQUEST['podlove-auphonic-production'];\n        $webhook_config = \\get_post_meta($post_id, 'auphonic_webhook_config', true);\n\n        [\n            'authkey' => $authkey,\n            'enabled' => $enabled\n        ] = $webhook_config;\n\n        if ($_REQUEST['authkey'] !== $authkey) {\n            \\Podlove\\Log::get()->addWarning(\n                'Auphonic webhook failed. AuthKey mismatch.',\n                ['post_id' => $post_id]\n            );\n\n            return;\n        }\n\n        $this->update_production_data($post_id);\n\n        if (\\Podlove\\Modules\\Plus\\FileStorage::is_enabled()) {\n            $transfer_status = get_post_meta($post_id, 'auphonic_plus_transfer_status', true);\n\n            if (empty($transfer_status) || $transfer_status === 'waiting_for_webhook') {\n                $this->plus_file_transfer->initiate_transfers($post_id);\n            }\n        }\n\n        if (!$enabled) {\n            \\Podlove\\Log::get()->addInfo(\n                'Auphonic webhook was enabled on production start but disabled during production. Episode data was updated but not published.',\n                ['post_id' => $post_id]\n            );\n\n            return;\n        }\n\n        \\wp_publish_post($post_id);\n        \\Podlove\\Log::get()->addInfo(\n            'Auphonic webhook finished. Episode published.',\n            ['post_id' => $post_id]\n        );\n        exit;\n    }\n\n    /**\n     * Updates Episode production data after Auphonic production has finished.\n     *\n     * @param mixed $post_id\n     */\n    public function update_production_data($post_id)\n    {\n        $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id);\n        $production = json_decode($this->fetch_production($_POST['uuid']), true)['data'];\n\n        $metadata = [\n            'title' => get_the_title($post_id),\n            'subtitle' => $episode->subtitle,\n            'summary' => $episode->summary,\n            'duration' => $episode->duration,\n            'chapters' => $episode->chapters,\n            'slug' => $episode->slug,\n            'license' => $episode->license,\n            'license_url' => $episode->license_url,\n            'tags' => implode(',', array_map(function ($tag) {\n                return $tag->name;\n            }, wp_get_post_tags($post_id))),\n        ];\n\n        $auphonic_metadata = [\n            'title' => $production['metadata']['title'],\n            'subtitle' => $production['metadata']['subtitle'],\n            'summary' => $production['metadata']['summary'],\n            'duration' => $production['length_timestring'],\n            'chapters' => $this->convert_chapters_to_string($production['chapters']),\n            'slug' => $production['output_basename'],\n            'license' => $production['metadata']['license'],\n            'license_url' => $production['metadata']['license_url'],\n            'tags' => implode(',', $production['metadata']['tags']),\n        ];\n\n        // Merge both arrays\n        foreach ($metadata as $metadata_key => $metadata_entry) {\n            if (is_null($metadata_entry) || empty($metadata_entry)) {\n                $metadata[$metadata_key] = $auphonic_metadata[$metadata_key];\n            }\n        }\n\n        $episode->subtitle = $metadata['subtitle'];\n        $episode->summary = $metadata['summary'];\n        $episode->duration = $metadata['duration'];\n        $episode->chapters = $metadata['chapters'];\n        $episode->slug = $metadata['slug'];\n        $episode->license = $metadata['license'];\n        $episode->license_url = $metadata['license_url'];\n        $episode->save();\n\n        wp_update_post([\n            'ID' => $post_id,\n            'post_title' => $metadata['title'],\n        ]);\n        wp_set_post_tags($post_id, $metadata['tags']);\n    }\n\n    /**\n     * Initiate PLUS file transfers for an episode.\n     *\n     * @param int $post_id\n     */\n    public function initiate_plus_file_transfers($post_id)\n    {\n        $this->plus_file_transfer->initiate_transfers($post_id);\n    }\n\n    /**\n     * Get PLUS file transfer queue for an episode.\n     *\n     * @param int $post_id\n     *\n     * @return array\n     */\n    public function get_plus_transfer_queue($post_id)\n    {\n        return $this->plus_file_transfer->get_transfer_queue($post_id);\n    }\n\n    /**\n     * Transfer a single PLUS file for an episode.\n     *\n     * @param int   $post_id\n     * @param array $file_data\n     *\n     * @return array\n     */\n    public function transfer_single_plus_file($post_id, $file_data)\n    {\n        return $this->plus_file_transfer->transfer_single_file($post_id, $file_data);\n    }\n\n    /**\n     * Set final PLUS transfer status after frontend processing.\n     *\n     * @param int         $post_id\n     * @param string      $status\n     * @param null|array  $files\n     * @param null|string $errors\n     * @param null|string $change_time\n     */\n    public function set_plus_transfer_final_status($post_id, $status, $files = null, $errors = null, $change_time = null)\n    {\n        $this->plus_file_transfer->set_final_transfer_status($post_id, $status, $files, $errors, $change_time);\n    }\n\n    public function convert_chapters_to_string($chapters)\n    {\n        if (!is_array($chapters)) {\n            return;\n        }\n\n        $chapters_string = '';\n\n        foreach ($chapters as $chapter) {\n            $chapters_string .= $chapter['start_output'].' ';\n            $chapters_string .= $chapter['title'];\n\n            if (!empty($chapter['url'])) {\n                $chapters_string = $chapters_string.' <'.$chapter['url'].'>';\n            }\n\n            $chapters_string .= \"\\n\";\n        }\n\n        return $chapters_string;\n    }\n\n    /**\n     * Refresh the list of auphonic presets.\n     */\n    public function ajax_refresh_presets()\n    {\n        delete_transient('podlove_auphonic_presets');\n        $result = $this->api->fetch_presets();\n\n        return \\Podlove\\AJAX\\AJAX::respond_with_json($result);\n    }\n\n    public function get_presets_list()\n    {\n        $presets = $this->api->fetch_presets();\n        if ($presets && is_array($presets->data)) {\n            $preset_list = [];\n\n            $raw_list = $presets->data;\n            usort($raw_list, function ($a, $b) {\n                return $a->preset_name <=> $b->preset_name;\n            });\n\n            foreach ($raw_list as $preset) {\n                $preset_list[$preset->uuid] = $preset->preset_name;\n            }\n        } else {\n            $preset_list[] = __('Presets could not be loaded', 'podlove-podcasting-plugin-for-wordpress');\n        }\n\n        return $preset_list;\n    }\n\n    /**\n     * Register a new Episode that can be published via Auphonic.\n     */\n    public function ajax_add_episode_for_auphonic_webhook()\n    {\n        $post_id = $_REQUEST['post_id'];\n        $auth_key = $_REQUEST['authkey'];\n        $action = $_REQUEST['flag'];\n\n        if (!$post_id || !$action || !$auth_key) {\n            return \\Podlove\\AJAX\\AJAX::respond_with_json(false);\n        }\n\n        $episodes_to_be_remote_published = get_option('podlove_episodes_to_be_remote_published');\n\n        if (!is_array($episodes_to_be_remote_published)) {\n            $episodes_to_be_remote_published = [];\n        }\n\n        $episodes_to_be_remote_published[$post_id] = [\n            'post_id' => $post_id,\n            'auth_key' => $auth_key,\n            'action' => $action,\n        ];\n        update_option('podlove_episodes_to_be_remote_published', $episodes_to_be_remote_published);\n\n        \\Podlove\\Log::get()->addDebug(\n            'Auphonic webhooks changed.',\n            ['data' => $episodes_to_be_remote_published]\n        );\n\n        return \\Podlove\\AJAX\\AJAX::respond_with_json(true);\n    }\n\n    /**\n     * Fetch name of logged in user via Auphonic API.\n     *\n     * Cached in transient \"podlove_auphonic_user\".\n     *\n     * @return string\n     */\n    public function fetch_authorized_user()\n    {\n        $cache_key = 'podlove_auphonic_user';\n\n        if (($user = get_transient($cache_key)) !== false) {\n            return $user;\n        }\n        if (!($token = $this->get_module_option('auphonic_api_key'))) {\n            return '';\n        }\n\n        $curl = new Http\\Curl();\n        $curl->request('https://auphonic.com/api/user.json', [\n            'headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->get_module_option('auphonic_api_key'),\n            ],\n        ]);\n        $response = $curl->get_response();\n\n        if ($curl->isSuccessful()) {\n            $decoded_user = json_decode($response['body']);\n            $user = $decoded_user ? $decoded_user : false;\n            set_transient($cache_key, $user, 60 * 60 * 24 * 365); // 1 year, we devalidate manually\n\n            return $user;\n        }\n\n        return false;\n    }\n\n    /**\n     * Fetch production via Auphonic APU.\n     *\n     * @param mixed $uuid\n     *\n     * @return string\n     */\n    public function fetch_production($uuid)\n    {\n        if (!($token = $this->get_module_option('auphonic_api_key'))) {\n            return '';\n        }\n\n        $curl = new Http\\Curl();\n        $curl->request('https://auphonic.com/api/production/'.$uuid.'.json', [\n            'headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->get_module_option('auphonic_api_key'),\n            ],\n        ]);\n        $response = $curl->get_response();\n\n        return $response['body'];\n    }\n\n    /**\n     * Fetch list of presets via Auphonic APU.\n     *\n     * Cached in transient \"podlove_auphonic_presets\".\n     *\n     * @return string\n     */\n    public function fetch_presets()\n    {\n        $cache_key = 'podlove_auphonic_presets';\n\n        if (($presets = get_transient($cache_key)) !== false) {\n            return $presets;\n        }\n        if (!($token = $this->get_module_option('auphonic_api_key'))) {\n            return '';\n        }\n\n        $curl = new Http\\Curl();\n        $curl->request('https://auphonic.com/api/presets.json', [\n            'headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->get_module_option('auphonic_api_key'),\n            ],\n        ]);\n        $response = $curl->get_response();\n\n        if ($curl->isSuccessful()) {\n            $presets = json_decode($response['body']);\n            set_transient($cache_key, $presets, 60 * 60 * 24 * 365); // 1 year, we devalidate manually\n\n            return $presets;\n        }\n\n        return [];\n    }\n\n    public function check_code()\n    {\n        if (isset($_GET['code']) && $_GET['code']) {\n            $ch = curl_init('https://auth.podlove.org/auphonic.php');\n            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');\n            curl_setopt($ch, CURLOPT_USERAGENT, \\Podlove\\Http\\Curl::user_agent());\n            curl_setopt($ch, CURLOPT_POSTFIELDS, [\n                'redirect_uri' => get_site_url().'/wp-admin/admin.php?page=podlove_settings_modules_handle',\n                'code' => $_GET['code'], ]);\n\n            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);\n\n            $result = curl_exec($ch);\n            $status_code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);\n            $curl_error = curl_error($ch);\n            curl_close($ch);\n\n            $result = is_string($result) ? trim($result) : '';\n\n            if ($result === '' || $status_code < 200 || $status_code >= 300) {\n                \\Podlove\\Log::get()->addError(\n                    'Auphonic OAuth token exchange failed.',\n                    [\n                        'status_code' => $status_code,\n                        'curl_error' => $curl_error,\n                        'response' => $result,\n                    ]\n                );\n\n                return;\n            }\n\n            \\Podlove\\Log::get()->addInfo('Auphonic OAuth token exchange returned a candidate token.', [\n                'token_length' => strlen($result),\n                'token_sha1_prefix' => substr(sha1($result), 0, 12),\n            ]);\n\n            if (!$this->validate_api_key($result)) {\n                \\Podlove\\Log::get()->addError(\n                    'Auphonic OAuth returned a token that could not be verified.',\n                    [\n                        'token_length' => strlen($result),\n                        'token_sha1_prefix' => substr(sha1($result), 0, 12),\n                    ]\n                );\n\n                return;\n            }\n\n            $this->update_module_option('auphonic_api_key', $result);\n            $this->clear_auphonic_cache();\n            header('Location: '.get_site_url().'/wp-admin/admin.php?page=podlove_settings_modules_handle');\n        }\n\n        if (isset($_GET['reset_auphonic_auth_code']) && $_GET['reset_auphonic_auth_code'] == '1') {\n            $this->update_module_option('auphonic_api_key', '');\n            $this->clear_auphonic_cache();\n            header('Location: '.get_site_url().'/wp-admin/admin.php?page=podlove_settings_modules_handle');\n        }\n    }\n\n    private function get_authorization_description()\n    {\n        $auth_url = add_query_arg([\n            'client_id' => self::OAUTH_CLIENT_ID,\n            'redirect_uri' => get_site_url().'/wp-admin/admin.php?page=podlove_settings_modules_handle',\n            'response_type' => 'code',\n        ], 'https://auphonic.com/oauth2/authorize/');\n        $user = $this->api->fetch_authorized_user();\n        $reset_link = '<a href=\"'.esc_url(admin_url('admin.php?page=podlove_settings_modules_handle&reset_auphonic_auth_code=1')).'\">'.__('Reset connection', 'podlove-podcasting-plugin-for-wordpress').'</a>';\n        // Build a temporary form on click because the module settings page is already inside the main WordPress options form.\n        $oauth_button = sprintf(\n            '<button type=\"button\" class=\"button button-primary\" onclick=\"%s\">%s</button>',\n            esc_attr(\n                \"var form=document.createElement('form');\"\n                .\"form.method='post';\"\n                .\"form.action='\".esc_js($auth_url).\"';\"\n                .'document.body.appendChild(form);'\n                .'form.submit();'\n            ),\n            esc_html__('Authorize with OAuth', 'podlove-podcasting-plugin-for-wordpress')\n        );\n\n        if (isset($user) && is_object($user) && is_object($user->data)) {\n            $status = '<i class=\"podlove-icon-ok\"></i> '\n                    .sprintf(\n                        __('Publisher is connected to Auphonic as %s.', 'podlove-podcasting-plugin-for-wordpress'),\n                        '<strong>'.esc_html($user->data->username).'</strong>'\n                    );\n        } elseif ($this->get_module_option('auphonic_api_key') != '') {\n            $status = '<i class=\"podlove-icon-remove\"></i> '\n                    .__('A stored Auphonic token could not be verified. Use OAuth, replace it with an API key, or reset the connection.', 'podlove-podcasting-plugin-for-wordpress');\n        } else {\n            $status = '<i class=\"podlove-icon-remove\"></i> '\n                    .__('No Auphonic credentials configured yet. Use OAuth or an API key to connect Publisher to Auphonic.', 'podlove-podcasting-plugin-for-wordpress');\n        }\n\n        return implode('<br>', array_filter([\n            $status,\n            '<span style=\"display:block; margin-top:0.75em;\"><strong>'.__('Use OAuth', 'podlove-podcasting-plugin-for-wordpress').'</strong></span>',\n            __('Authorize Publisher with your Auphonic account via OAuth. You will be redirected back here after Auphonic completes the authorization flow.', 'podlove-podcasting-plugin-for-wordpress'),\n            $oauth_button,\n            $this->get_module_option('auphonic_api_key') != '' ? $reset_link : '',\n        ]));\n    }\n\n    private function validate_api_key($token)\n    {\n        $curl = new Http\\Curl();\n        $curl->request('https://auphonic.com/api/user.json', [\n            'headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$token,\n            ],\n        ]);\n\n        if (!$curl->isSuccessful()) {\n            return false;\n        }\n\n        $response = $curl->get_response();\n        $decoded_user = json_decode($response['body']);\n\n        return $decoded_user && isset($decoded_user->data);\n    }\n\n    private function clear_auphonic_cache()\n    {\n        delete_transient('podlove_auphonic_user');\n        delete_transient('podlove_auphonic_presets');\n    }\n}\n"
  },
  {
    "path": "lib/modules/auphonic/episode_enhancer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Auphonic;\n\n/**\n * Auphonic Episode Enhancer.\n *\n * Adds an Auphonic interface to the episode management forms.\n */\nclass EpisodeEnhancer\n{\n    private $module;\n\n    public function __construct(Auphonic $module)\n    {\n        $this->module = $module;\n\n        if ($this->module->get_module_option('auphonic_api_key') != '') {\n            add_filter('podlove_episode_form_data', [$this, 'auphonic_episodes'], 10, 2);\n        }\n    }\n\n    public function auphonic_episodes($form_data, $episode)\n    {\n        $form_data[] = [\n            'type' => 'callback',\n            'key' => 'import_from_auphonic_form',\n            'options' => [\n                'callback' => [$this, 'auphonic_episodes_form'],\n            ],\n            'position' => 700,\n        ];\n\n        return $form_data;\n    }\n\n    public function auphonic_episodes_form()\n    {\n        ?>\n        <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n          <podlove-auphonic></podlove-auphonic>\n        </div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/auphonic/plus_file_transfer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Auphonic;\n\nclass PlusFileTransfer\n{\n    /**\n     * @var Auphonic\n     */\n    private $auphonic_module;\n\n    public function __construct(Auphonic $auphonic_module)\n    {\n        $this->auphonic_module = $auphonic_module;\n    }\n\n    /**\n     * Initiate PLUS file transfers for matching Auphonic output files.\n     *\n     * @param int $post_id\n     */\n    public function initiate_transfers($post_id)\n    {\n        $production = $this->get_production_data($post_id);\n        if (!$production) {\n            return;\n        }\n\n        $this->set_transfer_status($post_id, 'in_progress');\n\n        $matching_files = $this->get_matching_files($production['output_files'], $post_id);\n        if (empty($matching_files)) {\n            $this->set_transfer_status($post_id, 'completed');\n\n            return;\n        }\n\n        $episode = $this->get_episode($post_id);\n        if (!$episode) {\n            return;\n        }\n\n        $transfer_results = $this->process_file_transfers($matching_files, $episode, $post_id);\n        $this->handle_transfer_results($transfer_results, $post_id, $production['change_time'] ?? null);\n    }\n\n    /**\n     * Generate filename based on episode and matching asset.\n     *\n     * When uploading or importing files, their filenames may not match the\n     * expectations set by the asset system. Here we determine the filename as\n     * the Publisher expects it.\n     *\n     * @param string                 $original_filename\n     * @param \\Podlove\\Model\\Episode $episode\n     *\n     * @return string\n     */\n    public static function generate_filename($original_filename, $episode)\n    {\n        $matching_asset = self::find_matching_asset_for_filename($original_filename);\n\n        if ($matching_asset) {\n            $temp_media_file = new \\Podlove\\Model\\MediaFile();\n            $temp_media_file->episode_id = $episode->id;\n            $temp_media_file->episode_asset_id = $matching_asset->id;\n\n            return $temp_media_file->get_file_name();\n        }\n\n        // Fallback\n        $extension = pathinfo($original_filename, PATHINFO_EXTENSION);\n\n        return $episode->slug.'.'.$extension;\n    }\n\n    /**\n     * Get transfer queue without executing transfers.\n     *\n     * @param int $post_id\n     *\n     * @return array\n     */\n    public function get_transfer_queue($post_id)\n    {\n        $production = $this->get_production_data($post_id);\n        if (!$production) {\n            return [];\n        }\n\n        $matching_files = $this->get_matching_files($production['output_files'], $post_id);\n        if (empty($matching_files)) {\n            return [];\n        }\n\n        $episode = $this->get_episode($post_id);\n        if (!$episode) {\n            return [];\n        }\n\n        $transfer_queue = [];\n        foreach ($matching_files as $file) {\n            $file['filename'] = self::generate_filename($file['filename'], $episode);\n            $transfer_queue[] = $file;\n        }\n\n        return $transfer_queue;\n    }\n\n    /**\n     * Transfer a single file.\n     *\n     * @param int   $post_id\n     * @param array $file_data\n     *\n     * @return array\n     */\n    public function transfer_single_file($post_id, $file_data)\n    {\n        $plus_module = \\Podlove\\Modules\\Plus\\Plus::instance();\n\n        return $this->transfer_file_to_plus($plus_module, $file_data, $post_id);\n    }\n\n    /**\n     * Set final transfer status after frontend processing completes.\n     *\n     * @param int         $post_id\n     * @param string      $status\n     * @param null|array  $files\n     * @param null|string $errors\n     * @param null|string $change_time\n     */\n    public function set_final_transfer_status($post_id, $status, $files = null, $errors = null, $change_time = null)\n    {\n        update_post_meta($post_id, 'auphonic_plus_transfer_status', $status);\n\n        if ($files !== null) {\n            update_post_meta($post_id, 'auphonic_plus_transfer_files', $files);\n        }\n\n        if ($status === 'completed' && !empty($change_time)) {\n            update_post_meta($post_id, 'auphonic_plus_transfer_change_time', $change_time);\n        }\n\n        if (!empty($errors)) {\n            update_post_meta($post_id, 'auphonic_plus_transfer_errors', $errors);\n        } else {\n            delete_post_meta($post_id, 'auphonic_plus_transfer_errors');\n        }\n\n        \\Podlove\\Log::get()->addInfo(\n            'PLUS transfer final status updated.',\n            [\n                'post_id' => $post_id,\n                'status' => $status,\n                'files_count' => is_array($files) ? count($files) : 0,\n                'change_time' => $change_time\n            ]\n        );\n    }\n\n    /**\n     * Get and validate production data from Auphonic.\n     *\n     * @param int $post_id\n     *\n     * @return array|false\n     */\n    private function get_production_data($post_id)\n    {\n        $production = json_decode($this->auphonic_module->fetch_production($_POST['uuid']), true)['data'];\n\n        if (!isset($production['output_files']) || !is_array($production['output_files'])) {\n            \\Podlove\\Log::get()->addInfo(\n                'No output files found in Auphonic production data.',\n                ['post_id' => $post_id]\n            );\n\n            return false;\n        }\n\n        return $production;\n    }\n\n    /**\n     * Get files that match configured asset extensions.\n     *\n     * @param array $output_files\n     * @param int   $post_id\n     *\n     * @return array\n     */\n    private function get_matching_files($output_files, $post_id)\n    {\n        $configured_extensions = $this->get_configured_asset_extensions();\n        $matching_files = $this->filter_output_files_by_extensions($output_files, $configured_extensions);\n\n        if (empty($matching_files)) {\n            \\Podlove\\Log::get()->addInfo(\n                'No matching files found for configured asset extensions.',\n                ['post_id' => $post_id, 'configured_extensions' => $configured_extensions]\n            );\n        }\n\n        return $matching_files;\n    }\n\n    /**\n     * Get episode by post ID.\n     *\n     * @param int $post_id\n     *\n     * @return false|\\Podlove\\Model\\Episode\n     */\n    private function get_episode($post_id)\n    {\n        $episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post_id);\n        if (!$episode) {\n            \\Podlove\\Log::get()->addError(\n                'Could not find episode for post ID when generating filename.',\n                ['post_id' => $post_id]\n            );\n            $this->set_transfer_status($post_id, 'failed', 'Episode not found');\n\n            return false;\n        }\n\n        return $episode;\n    }\n\n    /**\n     * Process file transfers for all matching files.\n     *\n     * @param array                  $matching_files\n     * @param \\Podlove\\Model\\Episode $episode\n     * @param int                    $post_id\n     *\n     * @return array\n     */\n    private function process_file_transfers($matching_files, $episode, $post_id)\n    {\n        $plus_module = \\Podlove\\Modules\\Plus\\Plus::instance();\n        $transfer_results = [];\n\n        foreach ($matching_files as $file) {\n            $file['filename'] = self::generate_filename($file['filename'], $episode);\n            $result = $this->transfer_file_to_plus($plus_module, $file, $post_id);\n            $transfer_results[] = $result;\n        }\n\n        return $transfer_results;\n    }\n\n    /**\n     * Handle transfer results and set final status.\n     *\n     * @param array       $transfer_results\n     * @param int         $post_id\n     * @param null|string $change_time\n     */\n    private function handle_transfer_results($transfer_results, $post_id, $change_time = null)\n    {\n        $failed_transfers = array_filter($transfer_results, function ($result) {\n            return !$result['success'];\n        });\n\n        if (empty($failed_transfers)) {\n            $this->set_transfer_status($post_id, 'completed');\n            if (!empty($change_time)) {\n                update_post_meta($post_id, 'auphonic_plus_transfer_change_time', $change_time);\n            }\n            \\Podlove\\Log::get()->addInfo(\n                'All Auphonic files transferred successfully to PLUS storage.',\n                ['post_id' => $post_id, 'files_count' => count($transfer_results)]\n            );\n        } else {\n            $this->set_transfer_status($post_id, 'failed', 'Some files failed to transfer');\n            \\Podlove\\Log::get()->addError(\n                'Some Auphonic files failed to transfer to PLUS storage.',\n                ['post_id' => $post_id, 'failed_count' => count($failed_transfers)]\n            );\n        }\n\n        // Store transfer results for UI feedback\n        update_post_meta($post_id, 'auphonic_plus_transfer_files', $transfer_results);\n    }\n\n    /**\n     * Get configured asset extensions.\n     *\n     * @return array\n     */\n    private function get_configured_asset_extensions()\n    {\n        $episode_assets = \\Podlove\\Model\\EpisodeAsset::all();\n\n        $extensions = array_map(\n            fn ($asset) => ($file_type = $asset->file_type()) ? $file_type->extension : null,\n            $episode_assets\n        );\n\n        $filtered_extensions = array_filter(\n            $extensions,\n            fn ($extension) => !is_null($extension)\n        );\n\n        return array_unique($filtered_extensions);\n    }\n\n    /**\n     * Filter output files to only include those matching configured extensions.\n     *\n     * @param array $output_files\n     * @param array $configured_extensions\n     *\n     * @return array\n     */\n    private function filter_output_files_by_extensions($output_files, $configured_extensions)\n    {\n        return array_filter($output_files, function ($file) use ($configured_extensions) {\n            $filename = $file['filename'];\n\n            // we purposely do not use `pathinfo` here because one of our valid\n            // \"extensions\" is \"chapters.txt\" and that would not match.\n            return array_reduce(\n                $configured_extensions,\n                fn ($carry, $extension) => $carry || str_ends_with($filename, $extension),\n                false\n            );\n        });\n    }\n\n    /**\n     * Transfer a single file to PLUS storage.\n     *\n     * @param \\Podlove\\Modules\\Plus\\Plus $plus_module\n     * @param array                      $file_data\n     * @param int                        $post_id\n     *\n     * @return array\n     */\n    private function transfer_file_to_plus($plus_module, $file_data, $post_id)\n    {\n        try {\n            $filename = $file_data['filename'];\n            $download_url = $file_data['download_url'];\n\n            $api_key = $this->auphonic_module->get_module_option('auphonic_api_key');\n            $download_url = $download_url.'?bearer_token='.$api_key;\n\n            $result = $plus_module->get_api()->migrate_auphonic_file($download_url, $filename);\n\n            if ($result) {\n                return [\n                    'success' => true,\n                    'filename' => $filename,\n                    'download_url' => $download_url,\n                    'message' => 'File transferred successfully'\n                ];\n            }\n\n            return [\n                'success' => false,\n                'filename' => $filename,\n                'download_url' => $download_url,\n                'message' => 'File transfer failed'\n            ];\n        } catch (\\Exception $e) {\n            return [\n                'success' => false,\n                'filename' => $file_data['filename'] ?? 'unknown',\n                'download_url' => $file_data['download_url'] ?? 'unknown',\n                'message' => 'Transfer failed: '.$e->getMessage()\n            ];\n        }\n    }\n\n    private static function find_matching_asset_for_filename($original_filename)\n    {\n        $episode_assets = \\Podlove\\Model\\EpisodeAsset::all();\n\n        // Find all assets whose extension matches the end of the original filename\n        $matching_assets = array_filter($episode_assets, function ($asset) use ($original_filename) {\n            $file_type = $asset->file_type();\n\n            return $file_type && str_ends_with($original_filename, $file_type->extension);\n        });\n\n        if (empty($matching_assets)) {\n            return null;\n        }\n\n        if (count($matching_assets) === 1) {\n            return reset($matching_assets);\n        }\n\n        // If multiple matches, prefer the one with matching suffix\n        // First, try to find assets with suffixes that match the filename pattern\n        $assets_with_matching_suffix = array_filter($matching_assets, function ($asset) use ($original_filename) {\n            if (is_null($asset->suffix) || $asset->suffix === '' || strlen($asset->suffix) === 0) {\n                return false;\n            }\n\n            $suffix = $asset->suffix;\n            $extension = $asset->file_type()->extension;\n\n            return str_ends_with($original_filename, $suffix.'.'.$extension);\n        });\n\n        if (!empty($assets_with_matching_suffix)) {\n            return reset($assets_with_matching_suffix);\n        }\n\n        // If no suffix match, return the first asset without suffix\n        $assets_without_suffix = array_filter($matching_assets, function ($asset) {\n            return is_null($asset->suffix) || $asset->suffix === '' || strlen($asset->suffix) === 0;\n        });\n\n        if (!empty($assets_without_suffix)) {\n            return reset($assets_without_suffix);\n        }\n\n        // Fallback to first match\n        return reset($matching_assets);\n    }\n\n    /**\n     * Set transfer status for an episode.\n     *\n     * @param int    $post_id\n     * @param string $status        waiting_for_webhook|in_progress|completed|failed\n     * @param string $error_message\n     */\n    private function set_transfer_status($post_id, $status, $error_message = '')\n    {\n        update_post_meta($post_id, 'auphonic_plus_transfer_status', $status);\n\n        if ($error_message) {\n            update_post_meta($post_id, 'auphonic_plus_transfer_errors', $error_message);\n        } else {\n            delete_post_meta($post_id, 'auphonic_plus_transfer_errors');\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/auphonic/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Auphonic;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v2';\n    public const api_base = 'auphonic';\n\n    private $module;\n\n    public function __construct($module)\n    {\n        $this->module = $module;\n    }\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base.'/token', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_token'],\n                'permission_callback' => [$this, 'permission_check'],\n            ]\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/init-plus-file-transfer/(?P<production_uuid>[A-Za-z0-9\\-]+)/(?P<post_id>[0-9]+)', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'init_plus_file_transfer'],\n                'permission_callback' => [$this, 'permission_check'],\n                'args' => [\n                    'production_uuid' => [\n                        'required' => true,\n                        'type' => 'string',\n                        'pattern' => '^[A-Z)a-z0-9\\-]+$',\n                        'description' => 'The UUID of the Auphonic production'\n                    ],\n                    'post_id' => [\n                        'required' => true,\n                        'type' => 'integer',\n                        'description' => 'The ID of the post/episode'\n                    ]\n                ]\n            ]\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/transfer-single-file/(?P<production_uuid>[A-Za-z0-9\\-]+)/(?P<post_id>[0-9]+)', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'transfer_single_file'],\n                'permission_callback' => [$this, 'permission_check'],\n                'args' => [\n                    'production_uuid' => [\n                        'required' => true,\n                        'type' => 'string',\n                        'pattern' => '^[A-Za-z0-9\\-]+$',\n                        'description' => 'The UUID of the Auphonic production'\n                    ],\n                    'post_id' => [\n                        'required' => true,\n                        'type' => 'integer',\n                        'description' => 'The ID of the post/episode'\n                    ],\n                    'file_data' => [\n                        'required' => true,\n                        'type' => 'object',\n                        'description' => 'File data for transfer'\n                    ]\n                ]\n            ]\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/set-plus-transfer-status/(?P<production_uuid>[A-Za-z0-9\\-]+)/(?P<post_id>[0-9]+)', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'set_plus_transfer_status'],\n                'permission_callback' => [$this, 'permission_check'],\n                'args' => [\n                    'production_uuid' => [\n                        'required' => true,\n                        'type' => 'string',\n                        'pattern' => '^[A-Za-z0-9\\-]+$',\n                        'description' => 'The UUID of the Auphonic production'\n                    ],\n                    'post_id' => [\n                        'required' => true,\n                        'type' => 'integer',\n                        'description' => 'The ID of the post/episode'\n                    ],\n                    'status' => [\n                        'required' => true,\n                        'type' => 'string',\n                        'description' => 'Final transfer status'\n                    ],\n                    'files' => [\n                        'required' => false,\n                        'type' => 'array',\n                        'description' => 'Transfer results'\n                    ],\n                    'errors' => [\n                        'required' => false,\n                        'type' => ['string', 'null'],\n                        'description' => 'Error message if any'\n                    ],\n                    'change_time' => [\n                        'required' => false,\n                        'type' => ['string', 'null'],\n                        'description' => 'Auphonic production change timestamp for successful transfers'\n                    ]\n                ]\n            ]\n        ]);\n    }\n\n    public function get_token()\n    {\n        $key = $this->module->get_module_option('auphonic_api_key');\n\n        return rest_ensure_response($key);\n    }\n\n    public function init_plus_file_transfer($request)\n    {\n        $production_uuid = $request->get_param('production_uuid');\n        $post_id = $request->get_param('post_id');\n\n        if (!$production_uuid) {\n            return new \\WP_Error('invalid_production_uuid', 'Production UUID is required', ['status' => 400]);\n        }\n\n        if (!$post_id) {\n            return new \\WP_Error('invalid_post_id', 'Post ID is required', ['status' => 400]);\n        }\n\n        // Verify that the post and production are related\n        if (!$this->verify_post_production_relationship($post_id, $production_uuid)) {\n            return new \\WP_Error('post_production_mismatch', 'The specified post and production are not related', ['status' => 400]);\n        }\n\n        // Fetch the production data to get the associated post\n        $production_data = $this->module->fetch_production($production_uuid);\n\n        if (!$production_data) {\n            return new \\WP_Error('production_not_found', 'Could not fetch production data', ['status' => 404]);\n        }\n\n        $production = json_decode($production_data, true);\n\n        if (!$production || !isset($production['data'])) {\n            return new \\WP_Error('invalid_production_data', 'Invalid production data format', ['status' => 400]);\n        }\n\n        // Check if production is done\n        if ($production['data']['status_string'] !== 'Done') {\n            return new \\WP_Error('production_not_done', 'Production is not completed yet', ['status' => 400]);\n        }\n\n        // Set up $_POST to simulate webhook call\n        $_POST['uuid'] = $production_uuid;\n        $_POST['status_string'] = 'Done';\n\n        try {\n            $this->module->update_production_data($post_id);\n\n            if (\\Podlove\\Modules\\Plus\\FileStorage::is_enabled()) {\n                $transfer_queue = $this->module->get_plus_transfer_queue($post_id);\n\n                return rest_ensure_response([\n                    'success' => true,\n                    'message' => 'Transfer queue prepared',\n                    'transfer_queue' => $transfer_queue,\n                    'post_id' => $post_id,\n                    'production_uuid' => $production_uuid\n                ]);\n            }\n\n            return rest_ensure_response([\n                'success' => true,\n                'message' => 'PLUS file storage not enabled',\n                'transfer_queue' => [],\n                'post_id' => $post_id,\n                'production_uuid' => $production_uuid\n            ]);\n        } catch (\\Exception $e) {\n            return new \\WP_Error('transfer_failed', 'Failed to prepare transfer queue: '.$e->getMessage(), ['status' => 500]);\n        }\n    }\n\n    public function transfer_single_file($request)\n    {\n        $production_uuid = $request->get_param('production_uuid');\n        $post_id = $request->get_param('post_id');\n        $file_data = $request->get_param('file_data');\n\n        if (!$production_uuid) {\n            return new \\WP_Error('invalid_production_uuid', 'Production UUID is required', ['status' => 400]);\n        }\n\n        if (!$post_id) {\n            return new \\WP_Error('invalid_post_id', 'Post ID is required', ['status' => 400]);\n        }\n\n        if (!$file_data) {\n            return new \\WP_Error('invalid_file_data', 'File data is required', ['status' => 400]);\n        }\n\n        if (!$this->verify_post_production_relationship($post_id, $production_uuid)) {\n            return new \\WP_Error('post_production_mismatch', 'The specified post and production are not related', ['status' => 400]);\n        }\n\n        try {\n            $result = $this->module->transfer_single_plus_file($post_id, $file_data);\n\n            return rest_ensure_response($result);\n        } catch (\\Exception $e) {\n            return new \\WP_Error('transfer_failed', 'File transfer failed: '.$e->getMessage(), ['status' => 500]);\n        }\n    }\n\n    public function set_plus_transfer_status($request)\n    {\n        $production_uuid = $request->get_param('production_uuid');\n        $post_id = $request->get_param('post_id');\n        $status = $request->get_param('status');\n        $files = $request->get_param('files');\n        $errors = $request->get_param('errors');\n        $change_time = $request->get_param('change_time');\n\n        if (!$production_uuid) {\n            return new \\WP_Error('invalid_production_uuid', 'Production UUID is required', ['status' => 400]);\n        }\n\n        if (!$post_id) {\n            return new \\WP_Error('invalid_post_id', 'Post ID is required', ['status' => 400]);\n        }\n\n        if (!$status) {\n            return new \\WP_Error('invalid_status', 'Status is required', ['status' => 400]);\n        }\n\n        if (!$this->verify_post_production_relationship($post_id, $production_uuid)) {\n            return new \\WP_Error('post_production_mismatch', 'The specified post and production are not related', ['status' => 400]);\n        }\n\n        try {\n            // Convert empty/null errors to null for consistent handling\n            $errors = empty($errors) ? null : $errors;\n\n            $this->module->set_plus_transfer_final_status($post_id, $status, $files, $errors, $change_time);\n\n            return rest_ensure_response([\n                'success' => true,\n                'message' => 'Transfer status updated successfully'\n            ]);\n        } catch (\\Exception $e) {\n            return new \\WP_Error('status_update_failed', 'Failed to update transfer status: '.$e->getMessage(), ['status' => 500]);\n        }\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n\n    /**\n     * Verify that a post and production are related.\n     *\n     * @param int    $post_id\n     * @param string $production_uuid\n     *\n     * @return bool\n     */\n    private function verify_post_production_relationship($post_id, $production_uuid)\n    {\n        return get_post_meta($post_id, 'auphonic_production_id', true) === $production_uuid;\n    }\n}\n"
  },
  {
    "path": "lib/modules/automatic_numbering/automatic_numbering.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\AutomaticNumbering;\n\nuse Podlove\\Model;\n\nclass Automatic_Numbering extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Automatic Numbering';\n    protected $module_description = 'Automatically increase the Episode number when creating episodes.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_filter('podlove_model_defaults', [$this, 'override_episode_attribute_defaults'], 10, 2);\n    }\n\n    public function override_episode_attribute_defaults(array $defaults, $model)\n    {\n        if ($model::name() !== Model\\Episode::name()) {\n            return $defaults;\n        }\n\n        return $this->append_episode_number_to_defaults($defaults, $model);\n    }\n\n    public function append_episode_number_to_defaults(array $defaults, Model\\Episode $episode)\n    {\n        $next_number = Model\\Episode::get_next_episode_number();\n        $defaults['number'] = $next_number;\n\n        return $defaults;\n    }\n}\n"
  },
  {
    "path": "lib/modules/base.php",
    "content": "<?php\n\nnamespace Podlove\\Modules;\n\nabstract class Base\n{\n    /**\n     * Stores information about module options.\n     *\n     * @var array\n     */\n    protected $options = [];\n\n    protected function __construct() {}\n\n    private function __clone() {}\n\n    /**\n     * All Modules are singletons.\n     */\n    public static function instance()\n    {\n        static $instances = [];\n\n        $calledClass = get_called_class();\n\n        if (!isset($instances[$calledClass])) {\n            $instances[$calledClass] = new $calledClass();\n        }\n\n        return $instances[$calledClass];\n    }\n\n    /**\n     * This will be called to load the module.\n     *\n     * Here hooks can be registered, files be loaded etc.\n     * The module must not change any behavior before load() being called!\n     */\n    abstract public function load();\n\n    /**\n     * This will be called when the plugin is uninstalled.\n     *\n     * Modules can override this to drop their own tables or remove persistent data.\n     */\n    public function uninstall() {}\n\n    /**\n     * Fetch module names by iterating over module directories.\n     *\n     * @return array\n     */\n    public static function get_all_module_names()\n    {\n        $modules_dir = \\Podlove\\PLUGIN_DIR.'lib/modules/';\n        $modules = [];\n\n        if ($dhandle = opendir($modules_dir)) {\n            while (false !== ($fname = readdir($dhandle))) {\n                if (($fname != '.') && ($fname != '..') && is_dir($modules_dir.$fname)) {\n                    $modules[] = $fname;\n                }\n            }\n            closedir($dhandle);\n        }\n\n        return $modules;\n    }\n\n    /**\n     * Fetch module names for active modules only.\n     *\n     * @return array\n     */\n    public static function get_active_module_names()\n    {\n        $modules = self::get_all_module_names();\n\n        return array_merge(\n            array_filter($modules, function ($module) {\n                return Base::is_active($module);\n            }),\n            self::get_core_module_names()\n        );\n    }\n\n    /**\n     * Fetch module names for core modules only.\n     *\n     * @return array\n     */\n    public static function get_core_module_names()\n    {\n        return array_filter(self::get_all_module_names(), function ($module) {\n            $class = self::get_class_by_module_name($module);\n\n            return $class::is_core();\n        });\n    }\n\n    /**\n     * Get full class name for the main module class.\n     *\n     * @param string $module_name\n     *\n     * @return string\n     */\n    public static function get_class_by_module_name($module_name)\n    {\n        $class_name = podlove_snakecase_to_camelsnakecase($module_name);\n        $namespace_name = podlove_camelsnakecase_to_camelcase($class_name);\n\n        return \"\\\\Podlove\\\\Modules\\\\{$namespace_name}\\\\{$class_name}\";\n    }\n\n    public static function is_active($module_name)\n    {\n        $options = get_option('podlove_active_modules');\n\n        if (!is_array($options)) {\n            return false;\n        }\n\n        return isset($options[$module_name]);\n    }\n\n    public static function activate($module_name)\n    {\n        $options = get_option('podlove_active_modules');\n        if (!is_array($options)) {\n            $options = [];\n        }\n\n        if (!isset($options[$module_name])) {\n            $options[$module_name] = 'on';\n            update_option('podlove_active_modules', $options);\n        }\n    }\n\n    /**\n     * Is this module core functionality?\n     *\n     * Core modules are always-on and don't appear in modules list.\n     *\n     * @return bool\n     */\n    public static function is_core()\n    {\n        return false;\n    }\n\n    /**\n     * Is this module visible in the modules list?\n     *\n     * Use this method to hide a module even if it's not a core module.\n     *\n     * @return bool\n     */\n    public static function is_visible()\n    {\n        return true;\n    }\n\n    public function get_module_url()\n    {\n        return \\Podlove\\PLUGIN_URL.'/lib/modules/'.$this->get_module_directory_name();\n    }\n\n    public static function deactivate($module_name)\n    {\n        $options = get_option('podlove_active_modules');\n        if (!is_array($options)) {\n            $options = [];\n        }\n\n        if (isset($options[$module_name])) {\n            unset($options[$module_name]);\n            update_option('podlove_active_modules', $options);\n        }\n    }\n\n    /**\n     * Return public module name.\n     *\n     * @return string\n     */\n    public function get_module_name()\n    {\n        return $this->module_name;\n    }\n\n    /**\n     * Return public module description.\n     *\n     * @return string\n     */\n    public function get_module_description()\n    {\n        return $this->module_description;\n    }\n\n    /**\n     * Return public module group key.\n     *\n     * @return string\n     */\n    public function get_module_group()\n    {\n        return isset($this->module_group) ? $this->module_group : '';\n    }\n\n    /**\n     * Return option name of the field where module options are stored.\n     *\n     * @return string\n     */\n    public function get_module_options_name()\n    {\n        return 'podlove_module_'.$this->get_module_directory_name();\n    }\n\n    /**\n     * Return field of all module options.\n     *\n     * @return array\n     */\n    public function get_module_options()\n    {\n        return get_option($this->get_module_options_name(), []);\n    }\n\n    /**\n     * Return value for a single module option.\n     *\n     * @param string $name\n     * @param mixed  $default\n     *\n     * @return mixed\n     */\n    public function get_module_option($name, $default = null)\n    {\n        $options = $this->get_module_options();\n\n        return isset($options[$name]) ? $options[$name] : $default;\n    }\n\n    /**\n     * Set value for a single module option.\n     *\n     * @param string $name\n     * @param mixed  $value\n     */\n    public function update_module_option($name, $value)\n    {\n        $options = $this->get_module_options();\n        $options[$name] = $value;\n        update_option($this->get_module_options_name(), $options);\n    }\n\n    public function register_option($name, $input_type, $args)\n    {\n        $this->options[$name] = [\n            'input_type' => $input_type,\n            'args' => $args,\n        ];\n    }\n\n    public function get_registered_options()\n    {\n        return $this->options;\n    }\n\n    public static function is_module_settings_page()\n    {\n        // If I'm on the module page ...\n        if (filter_input(INPUT_GET, 'page') == 'podlove_settings_modules_handle') {\n            return true;\n        }\n\n        // ... or saving on the module page\n        if (\\Podlove\\is_options_save_page()) {\n            return true;\n        }\n\n        return false;\n    }\n\n    protected function get_module_class_name()\n    {\n        $fq_class_name = get_class($this);\n\n        return substr($fq_class_name, strrpos($fq_class_name, '\\\\') + 1);\n    }\n\n    protected function get_module_namespace_name()\n    {\n        return podlove_camelsnakecase_to_camelcase($this->get_module_class_name());\n    }\n\n    protected function get_module_directory_name()\n    {\n        return strtolower($this->get_module_class_name());\n    }\n}\n"
  },
  {
    "path": "lib/modules/categories/categories.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Categories;\n\nclass Categories extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Categories';\n    protected $module_description = 'Enable categories for episodes.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_filter('podlove_post_type_args', function ($args) {\n            $args['taxonomies'][] = 'category';\n\n            return $args;\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/contributor_group_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nclass Contributor_Group_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'contributor group',   // singular name of the listed records\n            'plural' => 'contributor groupes',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_title($group)\n    {\n        $actions = [\n            'edit' => Settings\\GenericEntitySettings::get_action_link('group', $group->id, __('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => Settings\\GenericEntitySettings::get_action_link('group', $group->id, __('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            Settings\\GenericEntitySettings::get_action_link('group', $group->id, $group->title),\n            $this->row_actions($actions)\n        ).'<input type=\"hidden\" class=\"group_id\" value=\"'.$group->id.'\">';\n    }\n\n    public function column_slug($role)\n    {\n        return $role->slug;\n    }\n\n    public function get_columns()\n    {\n        return [\n            'title' => __('Group Title', 'podlove-podcasting-plugin-for-wordpress'),\n            'slug' => __('Group Slug', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = 10;\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        $data = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::all('ORDER BY title ASC');\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/contributor_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nclass Contributor_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'contributor',   // singular name of the listed records\n            'plural' => 'contributors',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_avatar($contributor)\n    {\n        return $contributor\n            ->avatar()\n            ->setWidth(45)\n            ->image()\n        ;\n    }\n\n    public function column_realname($contributor)\n    {\n        $actions = [\n            'edit' => Settings\\GenericEntitySettings::get_action_link('contributor', $contributor->id, __('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => Settings\\GenericEntitySettings::get_action_link('contributor', $contributor->id, __('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n            'list' => $this->get_episodes_link($contributor, __('Show Episodes', 'podlove-podcasting-plugin-for-wordpress')),\n        ];\n\n        return sprintf(\n            '<strong>%1$s</strong><br /><em>%2$s %3$s</em><br />%4$s',\n            Settings\\GenericEntitySettings::get_action_link('contributor', $contributor->id, $contributor->getName()),\n            $contributor->realname,\n            $contributor->nickname == '' ? '' : ' ('.$contributor->nickname.')',\n            $this->row_actions($actions)\n        ).'<input type=\"hidden\" class=\"contributor_id\" value=\"'.$contributor->id.'\">';\n    }\n\n    public function column_identifier($contributor)\n    {\n        return $contributor->identifier;\n    }\n\n    public function column_gender($contributor)\n    {\n        if ($contributor->gender == 'none') {\n            return 'Not set';\n        }\n\n        return ucfirst($contributor->gender);\n    }\n\n    public function column_affiliation($contributor)\n    {\n        $affiliation = '';\n        $contributor->organisation == '' ? '' : $affiliation = $affiliation.'<strong>'.$contributor->organisation.'</strong><br />';\n        $contributor->department == '' ? '' : $affiliation = $affiliation.$contributor->department.'<br />';\n        $contributor->jobtitle == '' ? '' : $affiliation = $affiliation.'<em>'.$contributor->jobtitle.'</em><br />';\n\n        return $affiliation;\n    }\n\n    public function column_privateemail($contributor)\n    {\n        return \"<a href='mailto:\".$contributor->privateemail.\"'>\".$contributor->privateemail.'</a>';\n    }\n\n    public function column_default($contributor, $column_name)\n    {\n        return apply_filters('podlove_contributor_list_table_column_default', null, $contributor, $column_name);\n    }\n\n    public function column_visibility($contributor)\n    {\n        return $contributor->visibility ? '✓' : '×';\n    }\n\n    public function column_episodes($contributor)\n    {\n        return $this->get_episodes_link($contributor, $contributor->contributioncount);\n    }\n\n    public function column_social($contributor)\n    {\n        return $this->service_column_templates($contributor);\n    }\n\n    public function column_donation($contributor)\n    {\n        return $this->service_column_templates($contributor, 'donation');\n    }\n\n    public function get_columns()\n    {\n        $columns = [\n            'avatar' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n            'realname' => __('Contributor', 'podlove-podcasting-plugin-for-wordpress'),\n            'identifier' => __('Template ID', 'podlove-podcasting-plugin-for-wordpress'),\n            'gender' => __('Gender', 'podlove-podcasting-plugin-for-wordpress'),\n            'affiliation' => __('Affiliation', 'podlove-podcasting-plugin-for-wordpress'),\n            'privateemail' => __('Private E-mail', 'podlove-podcasting-plugin-for-wordpress'),\n            'episodes' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'visibility' => __('Visiblity', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        return apply_filters('podlove_contributor_list_table_columns', $columns);\n    }\n\n    public function search_form()\n    {\n        ?>\n\t\t<form method=\"post\">\n\t\t  <?php $this->search_box('search', 'search_id'); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    public function get_sortable_columns()\n    {\n        return [\n            'realname' => ['realname', false],\n            'identifier' => ['identifier', false],\n            'gender' => ['gender', false],\n            'affiliation' => ['organisation', false],\n            'privateemail' => ['privateemail', false],\n            'episodes' => ['contributioncount', true],\n            'visibility' => ['visibility', false],\n        ];\n    }\n\n    /**\n     * @override\n     */\n    public function display()\n    {\n        parent::display(); ?>\n\t\t<style type=\"text/css\">\n\t\t/* avoid mouseover jumping */\n\t\t#permanentcontributor { width: 160px; }\n\t\ttd.column-avatar, th.column-avatar { width: 50px; }\n\t\ttd.column-identifier, th.column-identifier { width: 12% !important; }\n\t\ttd.column-visibility, th.column-visibility { width: 7% !important; }\n\t\ttd.column-gender, th.column-gender { width: 7% !important; }\n\t\ttd.column-episodes, th.column-episodes { width: 8% !important; }\n\t\t.add-new-h2 { float: left; }\n\t\t</style>\n\t\t<?php\n    }\n\n    public function prepare_items()\n    {\n        global $wpdb;\n\n        // number of items per page\n        $per_page = get_user_meta(get_current_user_id(), 'podlove_contributors_per_page', true);\n        if (empty($per_page)) {\n            $per_page = 10;\n        }\n\n        // define column headers\n        $this->_column_headers = $this->get_column_info();\n\n        // look for order options\n\n        $orderby_whitelist = array_keys($this->get_sortable_columns());\n\n        if (isset($_GET['orderby']) && in_array($_GET['orderby'], $orderby_whitelist)) {\n            $orderby = 'ORDER BY '.$_GET['orderby'];\n        } else {\n            $orderby = 'ORDER BY contributioncount';\n        }\n\n        // look how to sort\n        if (strtolower((string) filter_input(INPUT_GET, 'order')) === 'asc') {\n            $order = 'ASC';\n        } else {\n            $order = 'DESC';\n        }\n\n        // retrieve data\n        if (!isset($_POST['s']) || empty($_POST['s'])) {\n            $data = \\Podlove\\Modules\\Contributors\\Model\\Contributor::all($orderby.' '.$order);\n        } else {\n            $search = \\Podlove\\esc_like($_POST['s']);\n            $search = '%'.$search.'%';\n\n            $search_columns = ['gender', 'organisation', 'identifier', 'department', 'jobtitle', 'privateemail', 'realname', 'publicname', 'guid'];\n            $search_columns = apply_filters('podlove_contributor_list_table_search_db_columns', $search_columns);\n\n            $like_searches = implode(' OR ', array_map(function ($column) use ($search) {\n                return '`'.$column.'` LIKE \\''.$search.'\\'';\n            }, $search_columns));\n\n            $data = \\Podlove\\Modules\\Contributors\\Model\\Contributor::all(\n                'WHERE '.$like_searches.' '.$orderby.' '.$order\n            );\n        }\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n\n        // Search box\n        $this->search_form();\n    }\n\n    public function no_items()\n    {\n        $url = sprintf('?page=%s&action=%s&podlove_tab=contributors', htmlspecialchars($_REQUEST['page'] ?? ''), 'new'); ?>\n\t\t<div style=\"margin: 20px 10px 10px 5px\">\n\t \t\t<span class=\"add-new-h2\" style=\"background: transparent\">\n\t\t\t<?php _e('No items found.'); ?>\n\t\t\t</span>\n\t\t\t<a href=\"<?php echo $url; ?>\" class=\"add-new-h2\">\n\t\t \t\t<?php _e('Add New'); ?>\n\t \t\t</a>\n\t \t</div>\n\t \t<?php\n    }\n\n    private function get_episodes_link($contributor, $title)\n    {\n        return sprintf(\n            '<a href=\"%s\">%s</a>',\n            admin_url('edit.php?post_type=podcast&contributor='.$contributor->id),\n            $title\n        );\n    }\n\n    private function service_column_templates($contributor, $type = 'social')\n    {\n        $contributor_services = \\Podlove\\Modules\\Social\\Model\\ContributorService::find_by_contributor_id_and_category($contributor->id, $type);\n        $source = '';\n\n        foreach ($contributor_services as $contributor_service) {\n            $service = $contributor_service->get_service();\n\n            $source .= '<li>'\n                    .$service->image()->setWidth(16)->image(['class' => 'podlove-contributor-list-social-logo'])\n                    .\"<a href='\".$contributor_service->get_service_url().\"'>\"\n                    .($service->url_scheme == '%account-placeholder%' ? 'link' : $contributor_service->value)\n                    .'</a>'\n                    .\"</li>\\n\";\n        }\n\n        return '<ul class=\"podlove-contributor-social-list\">'.$source.'</ul>';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/contributor_repair.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Repair;\n\nclass ContributorRepair\n{\n    public static function init()\n    {\n        add_action('podlove_repair_do_repair', [__CLASS__, 'fix_duplicate_contributions']);\n        add_filter('podlove_repair_descriptions', [__CLASS__, 'description']);\n    }\n\n    public static function description($descriptions)\n    {\n        return array_merge($descriptions, ['<strong>removes duplicate contributions</strong> if you have any']);\n    }\n\n    public static function fix_duplicate_contributions()\n    {\n        global $wpdb;\n\n        $contributions = self::find_duplicate_episode_contributions();\n\n        if (!is_array($contributions) || empty($contributions)) {\n            Repair::add_to_repair_log(__('Contributions did not need repair', 'podlove-podcasting-plugin-for-wordpress'));\n\n            return;\n        }\n\n        foreach ($contributions as $contribution) {\n            $sql = '\n\t\t\t\tDELETE FROM\n\t\t\t\t\t'.\\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::table_name().'\n\t\t\t\tWHERE\n\t\t\t\t\tid != '.$contribution['id'].'\n\t\t\t\t\tAND `contributor_id` = \"'.$contribution['contributor_id'].'\"\n\t\t\t\t\tAND `episode_id` = \"'.$contribution['episode_id'].'\"\n\t\t\t\t\tAND `role_id` = \"'.$contribution['role_id'].'\"\n\t\t\t\t\tAND `group_id` = \"'.$contribution['group_id'].'\"\n\t\t\t\t';\n            $wpdb->query($sql);\n\n            $ec = \\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::find_by_id($contribution['id']);\n            $ec->save(); // recalculates contribution count\n        }\n\n        Repair::add_to_repair_log(\n            sprintf(\n                _n('Deleted 1 duplicate contribution', 'Deleted %s duplicate contributions', count($contributions), 'podlove-podcasting-plugin-for-wordpress'),\n                count($contributions)\n            )\n        );\n    }\n\n    private static function find_duplicate_episode_contributions()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tid, contributor_id, episode_id, role_id, group_id, COUNT(*) cnt\n\t\t\tFROM\n\t\t\t\t'.\\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::table_name().'\n\t\t\tGROUP BY\n\t\t\t\tcontributor_id, episode_id, role_id, group_id\n\t\t\tHAVING\n\t\t\t\tcnt > 1\n\t\t\tORDER BY\n\t\t\t\tcnt DESC\n\t\t';\n\n        return $wpdb->get_results($sql, ARRAY_A);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/contributor_role_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nclass Contributor_Role_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'contributor role',   // singular name of the listed records\n            'plural' => 'contributor roles',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_title($role)\n    {\n        $actions = [\n            'edit' => Settings\\GenericEntitySettings::get_action_link('role', $role->id, __('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => Settings\\GenericEntitySettings::get_action_link('role', $role->id, __('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            Settings\\GenericEntitySettings::get_action_link('role', $role->id, $role->title),\n            $this->row_actions($actions)\n        ).'<input type=\"hidden\" class=\"role_id\" value=\"'.$role->id.'\">';\n    }\n\n    public function column_slug($role)\n    {\n        return $role->slug;\n    }\n\n    public function get_columns()\n    {\n        return [\n            'title' => __('Role Title', 'podlove-podcasting-plugin-for-wordpress'),\n            'slug' => __('Role Slug', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = 10;\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        $data = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::all('ORDER BY title ASC');\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/contributors.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Api\\Episodes\\WP_REST_PodloveEpisodeContributions_Controller;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\DefaultContribution;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\nuse Podlove\\Modules\\Contributors\\Model\\ShowContribution;\n\nclass Contributors extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Contributors';\n    protected $module_description = 'Manage contributors for each episode.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('podlove_module_was_activated_contributors', [$this, 'was_activated']);\n        add_filter('podlove_episode_form_data', [$this, 'contributors_form_for_episode'], 10, 2);\n        add_action('save_post', [$this, 'update_contributors'], 10, 2);\n        add_action('podlove_podcast_settings_tabs', [$this, 'podcast_settings_tab']);\n        add_action('update_option_podlove_podcast', [$this, 'save_setting'], 10, 2);\n        add_action('rest_api_init', [$this, 'api_init']);\n        add_filter('parse_query', [$this, 'filter_by_contributor']);\n\n        add_filter('manage_edit-podcast_columns', [$this, 'add_new_podcast_columns']);\n        add_action('manage_podcast_posts_custom_column', [$this, 'manage_podcast_columns']);\n\n        add_action('rss2_head', [$this, 'feed_head_contributors']);\n        add_action('podlove_append_to_feed_entry', [$this, 'feed_item_contributors'], 10, 4);\n\n        add_action('podlove_xml_export', [$this, 'expandExportFile']);\n        add_filter('podlove_import_jobs', [$this, 'expandImport']);\n\n        add_action('wp_ajax_podlove-contributors-delete-podcast', [$this, 'delete_podcast_contributor']);\n        add_action('wp_ajax_podlove-contributors-delete-default', [$this, 'delete_default_contributor']);\n\n        add_action('podlove_feed_settings_bottom', [$this, 'feed_settings']);\n        add_action('podlove_feed_process', [$this, 'feed_process'], 10, 2);\n\n        add_action('podlove_episode_created', [$this, 'apply_default_contributors']);\n\n        add_filter('podlove_twig_file_loader', function ($file_loader) {\n            $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'contributors', 'templates']), 'contributors');\n\n            return $file_loader;\n        });\n\n        add_filter('podlove_cache_tainting_classes', [$this, 'cache_tainting_classes']);\n\n        add_action('podlove_network_admin_bar_podcast', [$this, 'add_to_admin_bar_podcast'], 10, 2);\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'contributors',\n            ['\\Podlove\\Modules\\Contributors\\TemplateExtensions', 'accessorEpisodeContributors'],\n            5\n        );\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'contributors',\n            ['\\Podlove\\Modules\\Contributors\\TemplateExtensions', 'accessorPodcastContributors'],\n            4\n        );\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'contributor',\n            ['\\Podlove\\Modules\\Contributors\\TemplateExtensions', 'accessorPodcastContributor'],\n            4\n        );\n\n        // register shortcodes\n        new Shortcodes();\n\n        // on settings screen, save per_page option\n        add_filter('set-screen-option', function ($status, $option, $value) {\n            if ($option == 'podlove_contributors_per_page') {\n                return $value;\n            }\n\n            return $status;\n        }, 10, 3);\n\n        // register settings page\n        add_action('podlove_register_settings_pages', function ($settings_parent) {\n            new Settings\\ContributorSettings(\\Podlove\\Podcast_Post_Type::SETTINGS_PAGE_HANDLE);\n        });\n\n        // filter contributions in feeds\n        // add_filter('podlove_feed_contributions', array($this, 'must_have_uri'), 10, 2);\n        add_filter('podlove_feed_contributions', [$this, 'must_match_feed_role_and_group'], 10, 2);\n\n        ContributorRepair::init();\n        GenderStats::init();\n    }\n\n    public function uninstall()\n    {\n        Contributor::destroy();\n        ContributorRole::destroy();\n        ContributorGroup::destroy();\n        EpisodeContribution::destroy();\n        ShowContribution::destroy();\n        DefaultContribution::destroy();\n    }\n\n    public static function get_index_contributors_url()\n    {\n        return get_admin_url(get_current_blog_id(), 'admin.php?page=podlove_contributor_settings&podlove_tab=contributors');\n    }\n\n    public static function get_create_contributor_url()\n    {\n        return get_admin_url(get_current_blog_id(), 'admin.php?page=podlove_contributor_settings&podlove_tab=contributors&action=new');\n    }\n\n    public static function get_edit_contributor_url($contributor_id)\n    {\n        return get_admin_url(get_current_blog_id(), 'admin.php?page=podlove_contributor_settings&podlove_tab=contributors&action=edit&contributor='.$contributor_id);\n    }\n\n    public function add_to_admin_bar_podcast($wp_admin_bar, $podcast)\n    {\n        $podcast_toolbar_id = 'podlove_toolbar_'.$podcast;\n\n        $args = [\n            'id' => $podcast_toolbar_id.'_contributors',\n            'title' => __('Podlove Contributors', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent' => 'blog-'.$podcast,\n            'href' => self::get_index_contributors_url(),\n        ];\n        $wp_admin_bar->add_node($args);\n    }\n\n    public function must_have_uri($contributions, $feed)\n    {\n        return array_filter($contributions, function ($c) {\n            return is_object($c['contributor']) && strlen($c['contributor']->guid) > 0;\n        });\n    }\n\n    public function must_match_feed_role_and_group($contributions, $feed)\n    {\n        $option_name = 'podlove_feed_'.$feed->id.'_contributor_filter';\n        $filter = get_option($option_name);\n\n        if (!$filter) {\n            return $contributions;\n        }\n\n        return array_filter($contributions, function ($c) use ($filter) {\n            return (empty($filter['group']) || $c['contribution']->group_id == $filter['group'])\n                && (empty($filter['role']) || $c['contribution']->role_id == $filter['role']);\n        });\n    }\n\n    public function cache_tainting_classes($classes)\n    {\n        return array_merge($classes, [\n            Contributor::name(),\n            ContributorRole::name(),\n            ContributorGroup::name(),\n            EpisodeContribution::name(),\n            ShowContribution::name(),\n            DefaultContribution::name(),\n        ]);\n    }\n\n    /**\n     * Orders episode contributors by their 'orderby' and 'order' attribute.\n     *\n     * @param array $contributions List of contributions\n     * @param array $args          List of arguments. Keys: order, orderby\n     *\n     * @return Ordered list of cobtributions\n     */\n    public static function orderContributions($contributions, $args)\n    {\n        // Order by via attribute comperator\n        if (isset($args['orderby'])) {\n            $comperareFunc = null;\n\n            switch (strtoupper($args['orderby'])) {\n                case 'COMMENT':\n                    $comperareFunc = 'Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::sortByComment';\n\n                    break;\n                case 'POSITION':\n                    $comperareFunc = 'Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::sortByPosition';\n\n                    break;\n            }\n\n            $comperareFunc = apply_filters('podlove_order_contributions_compare_func', $comperareFunc, $args);\n\n            if ($comperareFunc && is_callable($comperareFunc)) {\n                usort($contributions, $comperareFunc);\n            }\n        }\n\n        // ASC or DESC order\n        if (!isset($args['order']) || strtoupper($args['order']) == 'DESC') {\n            $contributions = array_reverse($contributions);\n        }\n\n        return $contributions;\n    }\n\n    /**\n     * Filter contributions.\n     *\n     * @fixme {groupby: \"role\"} is missing\n     *\n     * @param array $contributions List of contributions\n     * @param array $args          List of arguments. Keys: role, group, groupby\n     *\n     * @return array Return format depends on `groupby` option. If it is not set,\n     *               a list of contributors is returned. Otherwise, a list of\n     *               hashes is returned. These hashes have one key `contributors`,\n     *               which contains the contributors. Depending on the `groupby`\n     *               setting there is also a `group` or `role` key, which contains\n     *               the expected object.\n     */\n    public static function filterContributions($contributions, $args)\n    {\n        // Remove all contributions with missing contributors.\n        $contributions = array_filter($contributions, function ($c) {\n            return (bool) $c->getContributor();\n        });\n\n        if (isset($args['id'])) {\n            $contributions = array_filter($contributions, function ($c) use ($args) {\n                return $c->getContributor()->identifier == $args['id'];\n            });\n        }\n\n        // filter by role\n        if (isset($args['role']) && $args['role'] != 'all') {\n            $role = $args['role'];\n            $contributions = array_filter($contributions, function ($c) use ($role) {\n                return $c->hasRole() && strtolower($role) == $c->getRole()->slug;\n            });\n        }\n\n        // filter by group\n        if (isset($args['group']) && $args['group'] != 'all') {\n            $group = $args['group'];\n            $contributions = array_filter($contributions, function ($c) use ($group) {\n                return $c->hasGroup() && strtolower($group) == $c->getGroup()->slug;\n            });\n        }\n\n        // reset keys\n        $contributions = array_values($contributions);\n\n        if (isset($args['groupby']) && $args['groupby'] == 'group') {\n            $groups = [];\n            foreach ($contributions as $contribution) {\n                $group = $contribution->getGroup();\n                if (is_object($group)) {\n                    if (isset($groups[$group->id])) {\n                        $groups[$group->id]['contributors'][] = new Template\\Contributor($contribution->getContributor(), $contribution);\n                    } else {\n                        $groups[$group->id] = [\n                            'group' => new Template\\ContributorGroup($group, [$contribution]),\n                            'contributors' => [new Template\\Contributor($contribution->getContributor(), $contribution)],\n                        ];\n                    }\n                } else { // handle contributors without a group\n                    if (isset($groups[0])) {\n                        $groups[0]['contributors'][] = new Template\\Contributor($contribution->getContributor(), $contribution);\n                    } else {\n                        $groups[0] = [\n                            'contributors' => [new Template\\Contributor($contribution->getContributor(), $contribution)],\n                        ];\n                    }\n                }\n            }\n\n            return $groups;\n        }\n        $contributors = array_map(function ($contribution) {\n            return new Template\\Contributor($contribution->getContributor(), $contribution);\n        }, $contributions);\n\n        // for convenience, return only one contributor if id parameter is used\n        if (isset($args['id']) && count($contributors)) {\n            return $contributors[0];\n        }\n\n        return $contributors;\n    }\n\n    /**\n     * Expands \"Import/Export\" module: export logic.\n     */\n    public function expandExportFile(\\SimpleXMLElement $xml)\n    {\n        Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributors', 'contributor', '\\Podlove\\Modules\\Contributors\\Model\\Contributor');\n        Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributor-groups', 'contributor-group', '\\Podlove\\Modules\\Contributors\\Model\\ContributorGroup');\n        Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributor-roles', 'contributor-role', '\\Podlove\\Modules\\Contributors\\Model\\ContributorRole');\n        Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributor-episode-contributions', 'contributor-episode-contribution', '\\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution');\n        Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributor-show-contributions', 'contributor-show-contribution', '\\Podlove\\Modules\\Contributors\\Model\\ShowContribution');\n    }\n\n    /**\n     * Expands \"Import/Export\" module: import logic.\n     *\n     * @param mixed $jobs\n     */\n    public function expandImport($jobs)\n    {\n        $jobs[] = '\\Podlove\\Modules\\Contributors\\Jobs\\PodcastImportContributorsJob';\n        $jobs[] = '\\Podlove\\Modules\\Contributors\\Jobs\\PodcastImportContributorGroupsJob';\n        $jobs[] = '\\Podlove\\Modules\\Contributors\\Jobs\\PodcastImportContributorRolesJob';\n        $jobs[] = '\\Podlove\\Modules\\Contributors\\Jobs\\PodcastImportContributorEpisodeContributionsJob';\n        $jobs[] = '\\Podlove\\Modules\\Contributors\\Jobs\\PodcastImportContributorShowContributionsJob';\n\n        return $jobs;\n    }\n\n    public function feed_head_contributors()\n    {\n        global $wp_query;\n\n        $feed = \\Podlove\\Model\\Feed::find_one_by_slug($wp_query->query_vars['feed']);\n\n        if (!$feed) {\n            return;\n        }\n\n        $contributor_xml = $this->prepare_contributions_for_feed(\n            \\Podlove\\Modules\\Contributors\\Model\\ShowContribution::all(),\n            $feed\n        );\n\n        echo apply_filters('podlove_feed_head_contributors', $contributor_xml);\n    }\n\n    public function feed_item_contributors($podcast, $episode, $feed, $format)\n    {\n        $contributor_xml = $this->prepare_contributions_for_feed(\n            \\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::find_all_by_episode_id($episode->id),\n            $feed\n        );\n\n        echo apply_filters('podlove_feed_contributors', $contributor_xml);\n    }\n\n    /**\n     * Allow to filter post list by contributor identifier.\n     *\n     * @param mixed $query\n     */\n    public function filter_by_contributor($query)\n    {\n        if (!isset($_GET['post_type']) || $_GET['post_type'] !== 'podcast') {\n            return;\n        }\n\n        if (!isset($_GET['contributor']) || empty($_GET['contributor'])) {\n            return;\n        }\n\n        if (!$contributor = Contributor::find_one_by_id($_GET['contributor'])) {\n            return;\n        }\n\n        $contributions = $contributor->getContributions();\n        $query->query_vars['post__in'] = array_map(function ($c) {\n            return is_object($c->getEpisode()) ? $c->getEpisode()->post_id : 0;\n        }, $contributions);\n    }\n\n    public function was_activated($module_name)\n    {\n        Contributor::build();\n        ContributorRole::build();\n        ContributorGroup::build();\n        EpisodeContribution::build();\n        ShowContribution::build();\n        DefaultContribution::build();\n    }\n\n    public function migrate_contributors($module_name)\n    {\n        $episodes = \\Podlove\\Episode::all();\n        $posted_contributors = [];\n\n        $args = [\n            'hierarchical' => false,\n            'labels' => [],\n            'show_ui' => true,\n            'show_tagcloud' => true,\n            'query_var' => true,\n            'rewrite' => ['slug' => 'contributor'],\n        ];\n\n        register_taxonomy('podlove-contributors', 'podcast', $args);\n\n        foreach (get_terms('podlove-contributors', 'orderby=count&hide_empty=0') as $contributorid => $contributor) {\n            $settings = $this->get_additional_settings_for_migration($contributor->term_id);\n\n            if (isset($settings['contributor_email'])) {\n                $privateemail = $settings['contributor_email'];\n            } else {\n                $privateemail = '';\n            }\n\n            $contributor_infos = ['realname' => $contributor->name,\n                'publicname' => $contributor->name,\n                'identifier' => $contributor->identifier,\n                'id' => $contributor->term_id,\n                'visibility' => 1,\n                'privateemail' => $privateemail, ];\n\n            $contributor_entry = new \\Podlove\\Modules\\Contributors\\Contributor();\n            $contributor_entry->update_attributes($contributor_infos);\n        }\n\n        foreach ($episodes as $episode_id => $episode_details) {\n            $terms = get_the_terms($episode_details->post_id, 'podlove-contributors');\n            if (isset($terms) and !empty($terms)) {\n                foreach ($terms as $term_id => $term_details) {\n                    $posted_contributors[] = ['id' => $term_details->term_id, 'slug' => $term_details->slug];\n                }\n            }\n            if (!empty($posted_contributors)) {\n                update_post_meta($episode_details->post_id, '_podlove_episode_contributors', wp_json_encode($posted_contributors));\n            }\n        }\n    }\n\n    public static function get_additional_settings_for_migration($term_id)\n    {\n        $all_contributor_settings = get_option('podlove_contributors', []);\n        if (!isset($all_contributor_settings[$term_id])) {\n            $all_contributor_settings[$term_id] = [];\n        }\n\n        return $all_contributor_settings[$term_id];\n    }\n\n    public function update_contributors($post_id)\n    {\n        if (!$post_id || !isset($_POST['episode_contributor'])) {\n            return;\n        }\n\n        $episode = Episode::find_one_by_post_id($post_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        foreach (\\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::find_all_by_episode_id($episode->id) as $contribution) {\n            $contribution->delete();\n        }\n\n        $position = 0;\n\n        foreach ($_POST['episode_contributor'] as $contributor_appearance) {\n            foreach ($contributor_appearance as $contributor_id => $contributor) {\n                if (!$contributor_id) {\n                    continue;\n                }\n\n                $c = new \\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution();\n\n                if (!empty($contributor['role'])) {\n                    $c->role_id = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::find_one_by_slug($contributor['role'])->id;\n                }\n\n                if (!empty($contributor['group'])) {\n                    $c->group_id = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::find_one_by_slug($contributor['group'])->id;\n                }\n\n                $c->episode_id = $episode->id;\n                $c->contributor_id = $contributor_id;\n                $c->comment = stripslashes($contributor['comment']);\n                $c->position = $position++;\n                $c->save();\n            }\n        }\n    }\n\n    public function contributors_form_for_episode($form_data)\n    {\n        $form_data[] = [\n            'type' => 'callback',\n            'key' => 'contributors_form_table',\n            'options' => [\n                'callback' => function () {\n                    ?>\n    <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n      <podlove-contributors></podlove-contributors>\n    </div>\n  <?php\n                }\n            ],\n            'position' => 500,\n        ];\n\n        return $form_data;\n    }\n\n    /**\n     * Contributors extension for podcast settings screen.\n     *\n     * @param mixed $tabs\n     */\n    public function podcast_settings_tab($tabs)\n    {\n        $tabs->addTab(new Settings\\PodcastContributorsSettingsTab('Contributors', __('Contributors', 'podlove-podcasting-plugin-for-wordpress')));\n\n        return $tabs;\n    }\n\n    /**\n     * @todo  this save logic belongs into the tab class\n     *\n     * @param mixed $old\n     * @param mixed $new\n     */\n    public function save_setting($old, $new)\n    {\n        if (!isset($new['contributor'])) {\n            return;\n        }\n\n        $contributor_appearances = $new['contributor'];\n\n        foreach (\\Podlove\\Modules\\Contributors\\Model\\ShowContribution::all() as $contribution) {\n            $contribution->delete();\n        }\n\n        $position = 0;\n        foreach ($contributor_appearances as $contributor_appearance) {\n            foreach ($contributor_appearance as $contributor_id => $contributor) {\n                $c = new \\Podlove\\Modules\\Contributors\\Model\\ShowContribution();\n\n                if (isset($contributor['role'])) {\n                    if ($role = ContributorRole::find_one_by_slug($contributor['role'])) {\n                        $c->role_id = $role->id;\n                    }\n                }\n\n                if (isset($contributor['group'])) {\n                    if ($group = ContributorGroup::find_one_by_slug($contributor['group'])) {\n                        $c->group_id = $group->id;\n                    }\n                }\n\n                if (isset($contributor['comment'])) {\n                    $c->comment = $contributor['comment'];\n                }\n\n                $c->contributor_id = $contributor_id;\n                $c->position = $position++;\n                $c->save();\n            }\n        }\n    }\n\n    public static function contributors_form_table($current_contributions = [], $form_base_name = 'episode_contributor')\n    {\n        $contributors_roles = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::selectOptions();\n        $contributors_groups = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::selectOptions();\n        $cjson = [];\n\n        // only valid contributions\n        $current_contributions = array_filter($current_contributions, function ($c) {\n            return $c->contributor_id > 0;\n        });\n\n        $has_roles = count($contributors_roles) > 0;\n        $has_groups = count($contributors_groups) > 0;\n        $can_be_commented = $form_base_name == 'podlove_contributor_defaults[contributor]' ? 0 : 1;\n\n        foreach (\\Podlove\\Modules\\Contributors\\Model\\Contributor::all() as $contributor) {\n            $show_contributions = \\Podlove\\Modules\\Contributors\\Model\\ShowContribution::all('WHERE `contributor_id` = '.$contributor->id);\n            if (empty($show_contributions)) {\n                $cjson[$contributor->id] = [\n                    'id' => $contributor->id,\n                    'slug' => $contributor->identifier,\n                    'role' => '',\n                    'group' => '',\n                    'realname' => $contributor->realname,\n                    'avatar' => $contributor->avatar()->setWidth(45)->image(),\n                ];\n            } else {\n                foreach ($show_contributions as $show_contribution) {\n                    $role_data = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::find_one_by_id($show_contribution->role_id);\n                    $role_data == '' ? $role = '' : $role = $role_data->id;\n                    $group_data = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::find_one_by_id($show_contribution->group_id);\n                    $group_data == '' ? $group = '' : $group = $group_data->id;\n                    $cjson[$contributor->id] = [\n                        'id' => $contributor->id,\n                        'slug' => $contributor->identifier,\n                        'role' => $role,\n                        'group' => $group,\n                        'realname' => $contributor->realname,\n                        'avatar' => $contributor->avatar()->setWidth(45)->image(),\n                    ];\n                }\n            }\n        }\n\n        // override contributor roles and groups with scoped roles\n        foreach ($current_contributions as $contribution_key => $current_contribution) {\n            if ($role = $current_contribution->getRole()) {\n                $cjson[$current_contribution->contributor_id]['role'] = $role->slug;\n            }\n            if ($group = $current_contribution->getGroup()) {\n                $cjson[$current_contribution->contributor_id]['group'] = $group->slug;\n            }\n        }\n\n        $contributors = \\Podlove\\Modules\\Contributors\\Model\\Contributor::all();\n\n        $existing_contributions = array_filter(array_map(function ($c) {\n            // Set default role\n            $role_data = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::find_by_id($c->role_id);\n            if (isset($role_data)) {\n                $role = $role_data->slug;\n            } else {\n                if (empty($c->role)) {\n                    $role = '';\n                } else {\n                    $role = $c->role->slug;\n                }\n            }\n\n            // Set default group\n            $group_data = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::find_by_id($c->group_id);\n            if (isset($group_data)) {\n                $group = $group_data->slug;\n            } else {\n                if (empty($c->group)) {\n                    $group = '';\n                } else {\n                    $group = $c->group->slug;\n                }\n            }\n\n            if (is_object(\\Podlove\\Modules\\Contributors\\Model\\Contributor::find_by_id($c->contributor_id))) {\n                return ['id' => $c->contributor_id, 'role' => $role, 'group' => $group, 'comment' => $c->comment];\n            }\n\n            return '';\n        }, $current_contributions));\n\n        \\Podlove\\load_template(\n            'lib/modules/contributors/views/form_table',\n            compact(\n                'has_groups',\n                'has_roles',\n                'can_be_commented',\n                'form_base_name',\n                'existing_contributions',\n                'cjson',\n                'contributors',\n                'contributors_groups',\n                'contributors_roles'\n            )\n        );\n    }\n\n    public function add_new_podcast_columns($columns)\n    {\n        $keys = array_keys($columns);\n        $insertIndex = array_search('author', $keys) + 1; // after author column\n\n        // insert contributors at that index\n        return array_slice($columns, 0, $insertIndex, true)\n                   + ['contributors' => __('Contributors', 'podlove-podcasting-plugin-for-wordpress')]\n                   + array_slice($columns, $insertIndex, count($columns) - 1, true);\n    }\n\n    public function manage_podcast_columns($column_name)\n    {\n        switch ($column_name) {\n            case 'contributors':\n                $episode = \\Podlove\\Model\\Episode::find_one_by_post_id(get_the_ID());\n\n                if (!$episode) {\n                    return;\n                }\n\n                $contributors = \\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution::find_all_by_episode_id($episode->id);\n                $contributor_list = '';\n\n                foreach ($contributors as $contributor_id => $contributor) {\n                    $contributor_details = $contributor->getContributor();\n\n                    if (is_object($contributor_details)) {\n                        $contributor_list = $contributor_list.'<a href=\"'.site_url().'/wp-admin/edit.php?post_type=podcast&contributor='.$contributor_details->id.'\">'.$contributor_details->getName().'</a>, ';\n                    }\n                }\n\n                echo substr($contributor_list, 0, -2);\n\n                break;\n        }\n    }\n\n    public function delete_podcast_contributor()\n    {\n        if (!current_user_can('podlove_manage_contributors')) {\n            return;\n        }\n\n        if (!\\wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $object_id = (int) $_REQUEST['object_id'];\n\n        if (!$object_id) {\n            return;\n        }\n\n        if ($service = ShowContribution::find_by_id($object_id)) {\n            $service->delete();\n        }\n    }\n\n    public function delete_default_contributor()\n    {\n        if (!current_user_can('podlove_manage_contributors')) {\n            return;\n        }\n\n        if (!\\wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {\n            http_response_code(401);\n            exit;\n        }\n\n        $object_id = (int) $_REQUEST['object_id'];\n\n        if (!$object_id) {\n            return;\n        }\n\n        if ($service = DefaultContribution::find_by_id($object_id)) {\n            $service->delete();\n        }\n    }\n\n    public function feed_settings($wrapper)\n    {\n        if (!isset($_REQUEST['feed'])) {\n            return;\n        }\n\n        $contributors_roles = \\Podlove\\Modules\\Contributors\\Model\\ContributorRole::all();\n        $contributors_groups = \\Podlove\\Modules\\Contributors\\Model\\ContributorGroup::all();\n        $option_name = 'podlove_feed_'.$_REQUEST['feed'].'_contributor_filter';\n\n        $selected_filter = get_option($option_name);\n\n        if (!$selected_filter) {\n            $selected_filter = [\n                'group' => null,\n                'role' => null,\n            ];\n        }\n\n        $wrapper->subheader(__('Contributors', 'podlove-podcasting-plugin-for-wordpress'));\n        $wrapper->callback('services_form_table', [\n            'label' => __('Contributor Filter', 'podlove-podcasting-plugin-for-wordpress'),\n            'callback' => function () use ($contributors_roles, $contributors_groups, $selected_filter) {\n                ?>\n\t\t\t\t\t<select name=\"podlove_feed[contributor_filter][group]\" id=\"\">\n\t\t\t\t\t\t<option value=\"\"></option>\n\t\t\t\t\t\t<?php\n                            foreach ($contributors_groups as $group) {\n                                echo \"<option value='\".$group->id.\"' \".($group->id == $selected_filter['group'] ? 'selected' : '').'>'.$group->title.'</option>';\n                            } ?>\n\t\t\t\t\t</select>\n\t\t\t\t\t<?php echo __('Group', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\n\t\t\t\t\t<select name=\"podlove_feed[contributor_filter][role]\" id=\"\">\n\t\t\t\t\t\t<option value=\"\"></option>\n\t\t\t\t\t\t<?php\n                            foreach ($contributors_roles as $role) {\n                                echo \"<option value='\".$role->id.\"' \".($role->id == $selected_filter['role'] ? 'selected' : '').'>'.$role->title.'</option>';\n                            } ?>\n\t\t\t\t\t</select>\n\t\t\t\t\t<?php echo __('Role', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<span class=\"description\"><?php echo __('Limit contributors to the given group and/or role.', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n        ]);\n\n        return $wrapper;\n    }\n\n    public function feed_process($feed_id, $action)\n    {\n        if (!$_POST) {\n            return;\n        }\n\n        $group = $_POST['podlove_feed']['contributor_filter']['group'];\n        $role = $_POST['podlove_feed']['contributor_filter']['role'];\n        $option_name = 'podlove_feed_'.$feed_id.'_contributor_filter';\n\n        update_option($option_name, ['group' => $group, 'role' => $role]);\n    }\n\n    public function api_init()\n    {\n        $api_v1 = new REST_API();\n        $api_v1->register_routes();\n        $api_v2 = new WP_REST_PodloveContributors_Controller();\n        $api_v2->register_routes();\n        $api_episode_contributor = new WP_REST_PodloveEpisodeContributions_Controller();\n        $api_episode_contributor->register_routes();\n    }\n\n    public function apply_default_contributors(Episode $episode)\n    {\n        $defaults = DefaultContribution::all();\n        usort($defaults, function ($a, $b) { return $a->position <=> $b->position; });\n\n        foreach ($defaults as $default_contribution) {\n            $contribution = new EpisodeContribution();\n            $contribution->episode_id = $episode->id;\n            $contribution->contributor_id = $default_contribution->contributor_id;\n            $contribution->position = $default_contribution->position;\n            $contribution->role_id = $default_contribution->role_id;\n            $contribution->group_id = $default_contribution->group_id;\n            $contribution->save();\n        }\n    }\n\n    /**\n     * Prepare contributions for output in feed.\n     *\n     *\t- applies various filters\n     *\t- generates and returns feed-compatible xml\n     *\n     * @param array  $raw_contributions\n     * @param object $feed\n     *\n     * @return string\n     */\n    private function prepare_contributions_for_feed($raw_contributions, $feed)\n    {\n        $contributions = [];\n        foreach ($raw_contributions as $contribution) {\n            $contributions[] = [\n                'contributor' => $contribution->getContributor(),\n                'contribution' => $contribution,\n            ];\n        }\n\n        $contributions = apply_filters('podlove_feed_contributions', $contributions, $feed);\n\n        $contributor_xml = '';\n        // atom:contributor\n        foreach ($contributions as $contribution) {\n            $contributor_xml .= $this->getContributorXML($contribution['contributor']);\n        }\n        // podcast:person\n        foreach ($contributions as $contribution) {\n            $contributor_xml .= $this->getPodcastindexContributorXML($contribution['contributor'], $contribution['contribution']);\n        }\n\n        return $contributor_xml;\n    }\n\n    private function getContributorXML($contributor)\n    {\n        $contributor_xml = '';\n\n        if ($contributor->visibility == 1) {\n            $dom = new \\Podlove\\DomDocumentFragment();\n\n            $xml = $dom->createElement('atom:contributor');\n\n            // add the empty name tag\n            $name = $dom->createElement('atom:name');\n            $xml->appendChild($name);\n\n            // fill name tag with escaped content\n            $name_text = $dom->createTextNode($contributor->getName());\n            $name->appendChild($name_text);\n\n            if ($contributor->guid) {\n                $xml->appendChild($dom->createElement('atom:uri', $contributor->guid));\n            }\n\n            $dom->appendChild($xml);\n\n            $contributor_xml .= (string) $dom;\n        }\n\n        return $contributor_xml;\n    }\n\n    /**\n     * get contributor xml in podcastindex \"person\" format.\n     *\n     * @todo take first social URL and add it as href attribute\n     *\n     * @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person\n     *\n     * @param mixed $contributor\n     * @param mixed $contribution\n     */\n    private function getPodcastindexContributorXML($contributor, $contribution)\n    {\n        $contributor_xml = '';\n\n        if ($contributor->visibility == 1) {\n            $dom = new \\Podlove\\DomDocumentFragment();\n\n            $xml = $dom->createElement('podcast:person');\n\n            $name_text = $dom->createTextNode($contributor->getName());\n            $xml->appendChild($name_text);\n\n            $dom->appendChild($xml);\n\n            $xml->setAttribute('img', $contributor->avatar()->url());\n\n            // proof of concept for roles/groups\n            // next implementation should fully support all options\n            // @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/taxonomy.json\n            if ($role = $contribution->getRole()) {\n                $role_title = strtolower($role->title);\n\n                switch ($role_title) {\n                    case 'guest':\n                    case 'gast':\n                        $matching_role = 'guest';\n\n                        break;\n                    case 'host':\n                    case 'team':\n                        $matching_role = 'host';\n\n                        break;\n                    case 'sponsor':\n                        $matching_role = 'sponsor';\n\n                        break;\n\n                    default:\n                        $matching_role = null;\n\n                        break;\n                }\n\n                if ($matching_role) {\n                    $xml->setAttribute('role', $matching_role);\n                }\n            }\n\n            $contributor_xml .= (string) $dom;\n        }\n\n        return $contributor_xml;\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/gender_stats.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\n\nclass GenderStats\n{\n    public static function init()\n    {\n        add_action('podlove_dashboard_meta_boxes', [__CLASS__, 'dashboard_gender_statistics']);\n        add_filter('podlove_dashboard_statistics_network', [__CLASS__, 'dashboard_network_statistics_row']);\n    }\n\n    public static function dashboard_gender_statistics()\n    {\n        add_meta_box(\n            \\Podlove\\Settings\\Dashboard::$pagehook.'_gender',\n            __('Gender Statistics', 'podlove-podcasting-plugin-for-wordpress'),\n            [__CLASS__, 'dashboard_gender_statistics_widget'],\n            \\Podlove\\Settings\\Dashboard::$pagehook,\n            'normal',\n            'default'\n        );\n    }\n\n    public static function dashboard_gender_statistics_widget($post)\n    {\n        if (EpisodeContribution::count() === 0) {\n            ?>\n\t\t\t<p>\n\t\t\t\t<?php echo __('Gender statistics will be available once you start assigning contributors to episodes.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<?php\n            return;\n        }\n\n        $gender_distribution = self::fetch_contributors_for_dashboard_statistics(); ?>\n\t\t<div class=\"podlove_gender_widget_column\">\n\t\t\t<h4><?php _e('Total', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t\t<table cellspacing=\"0\" cellspadding=\"0\">\n\t\t\t\t<thead>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<th><?php _e('Female', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th><?php _e('Male', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th><?php _e('Not Attributed', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($gender_distribution['global']['by_gender']['female'], $gender_distribution['global']['total']); ?>%</td>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($gender_distribution['global']['by_gender']['male'], $gender_distribution['global']['total']); ?>%</td>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($gender_distribution['global']['by_gender']['none'], $gender_distribution['global']['total']); ?>%</td>\n\t\t\t\t\t</tr>\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</div>\n\t\t<div class=\"podlove_gender_widget_column\">\n\t\t\t<h4><?php _e('By Group', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t\t<?php self::group_or_role_stats_table('group', $gender_distribution['by_group']); ?>\n\t\t</div>\n\t\t<div class=\"podlove_gender_widget_column\">\n\t\t\t<h4><?php _e('By Role', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t\t<?php self::group_or_role_stats_table('role', $gender_distribution['by_role']); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public static function dashboard_network_statistics_row($genders)\n    {\n        $podcasts = \\Podlove\\Modules\\Networks\\Model\\Network::podcast_blog_ids();\n        $podcasts_with_contributors_active = 0;\n        $relative_gender_numbers = [\n            'male' => 0,\n            'female' => 0,\n            'none' => 0,\n        ];\n\n        foreach ($podcasts as $podcast) {\n            switch_to_blog($podcast);\n            if (\\Podlove\\Modules\\Base::is_active('contributors')) {\n                $global_gender_numbers = self::fetch_contributors_for_dashboard_statistics();\n                if ($global_gender_numbers['global']['total'] > 0) {\n                    foreach ($global_gender_numbers['global']['by_gender'] as $gender => $number_of_contributions) {\n                        $relative_gender_numbers[$gender] += $number_of_contributions / $global_gender_numbers['global']['total'] * 100;\n                    }\n                }\n                ++$podcasts_with_contributors_active;\n            }\n            restore_current_blog();\n        } ?>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php echo __('Genders', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php\n                echo implode(', ', array_map(function ($percent, $gender) use ($podcasts_with_contributors_active) {\n                    return round($percent / $podcasts_with_contributors_active).'% '.($gender == 'none' ? 'not attributed' : $gender);\n                }, $relative_gender_numbers, array_keys($relative_gender_numbers))); ?>\n\t\t\t</td>\n\t\t</tr>\n\t\t<?php\n    }\n\n    private static function get_percentage($value, $relative_to)\n    {\n        if ($relative_to === 0) {\n            return '—';\n        }\n\n        return round($value / $relative_to * 100);\n    }\n\n    private static function group_or_role_stats_table($context, $numbers)\n    {\n        ?>\n\t\t<table cellspacing=\"0\" cellspadding=\"0\">\n\t\t\t<thead>\n\t\t\t\t<tr>\n\t\t\t\t\t<th><?php echo $context == 'group' ? __('Group', 'podlove-podcasting-plugin-for-wordpress') : __('Role', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t<th><?php _e('Female', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t<th><?php _e('Male', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t<th><?php _e('Not Attributed', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t</tr>\n\t\t\t</thead>\n\t\t\t<tbody>\n\t\t\t<?php\n            foreach ($numbers as $group_or_role_id => $group_or_role_numbers) {\n                $group_or_role = ($context == 'group' ? ContributorGroup::find_one_by_id($group_or_role_id) : ContributorRole::find_one_by_id($group_or_role_id)); // This return either a group or a role object\n\n                if (!$group_or_role) {\n                    continue;\n                } ?>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td><?php echo $group_or_role->title; ?></td>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($group_or_role_numbers['by_gender']['female'], $group_or_role_numbers['total']); ?>%</td>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($group_or_role_numbers['by_gender']['male'], $group_or_role_numbers['total']); ?>%</td>\n\t\t\t\t\t\t<td><?php echo self::get_percentage($group_or_role_numbers['by_gender']['none'], $group_or_role_numbers['total']); ?>%</td>\n\t\t\t\t\t</tr>\n\t\t\t\t<?php\n            } ?>\n\t\t\t</tbody>\n\t\t</table>\n\t\t<?php\n    }\n\n    private static function fetch_contributors_for_dashboard_statistics()\n    {\n        return \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('podlove_dashboard_stats_contributors', function () {\n            return (new Model\\ContributionGenderStatistics())->get();\n        }, 3600);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/jobs/podcast_import_contributor_episode_contributions_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorEpisodeContributionsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributor Episode Contributions';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributor Episode Contributions';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributor-episode-contribution';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/jobs/podcast_import_contributor_groups_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorGroupsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributor Groups';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributor Groups';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Contributors\\Model\\ContributorGroup';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributor-group';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/jobs/podcast_import_contributor_roles_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorRolesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributor Roles';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributor Roles';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Contributors\\Model\\ContributorRole';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributor-role';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/jobs/podcast_import_contributor_show_contributions_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorShowContributionsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributor Show Contributions';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributor Show Contributions';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Contributors\\Model\\ShowContribution';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributor-show-contribution';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/jobs/podcast_import_contributors_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributors';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributors';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Contributors\\Model\\Contributor';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributor';\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/js/admin.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n(function($) {\n\n\t$(document).ready(function(){\n\t\tif ( $(\"#add_contributors_submit\").length ) {\n\t\t\t$(\"#add_contributors_submit\").on(\"click\", function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\tvar $add_contributors_input = $(\"#add_contributors_input\");\n\t\t\t\tvar new_contributor = $add_contributors_input.val();\n\n\t\t\t\tif (!new_contributor.length) return false;\n\n\t\t\t\tPODLOVE.add_contributor({\n\t\t\t\t\tslug: new_contributor,\n\t\t\t\t\tid: 0,\n\t\t\t\t\tavatar: null,\n\t\t\t\t\tname: new_contributor\n\t\t\t\t});\n\n\t\t\t\treturn false;\n\t\t\t});\n\n\t\t\t$(\"#contributors\").on(\"click\", \".contributor a.ntdelbutton\", function(e) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\tvar $contributor = $(this).closest(\".contributor\");\n\n\t\t\t\t// actually remove contributor\n\t\t\t\tvar slug = $contributor.data('termSlug');\n\n\t\t\t\tvar $data_field = $(\"#tax-input-podlove-contributors\");\n\t\t\t\tvar contributors = $data_field.val().split(\",\");\n\n\t\t\t\t// remove by slug\n\t\t\t\tcontributors.splice(contributors.indexOf(slug),1);\n\t\t\t\t$data_field.val(contributors.join(\",\"));\n\n\t\t\t\t// visurally remove contributor\n\t\t\t\t$contributor.remove();\n\n\t\t\t\treturn false;\n\t\t\t});\n\n\t\t\tvar $input = $(\"#add_contributors_input\").autocomplete({\n\t\t\t\tminLength: 0,\n\t\t\t\tsource: PODLOVE.people,\n\t\t\t\tfocus: function(event, ui) {\n\t\t\t\t\t$(\"#add_contributors_input\").val(ui.item.label);\n\n\t\t\t\t\treturn false;\n\t\t\t\t},\n\t\t\t\tselect: function(event, ui) {\n\n\t\t\t\t\tPODLOVE.add_contributor({\n\t\t\t\t\t\tslug: ui.item.value,\n\t\t\t\t\t\tid: ui.item.id,\n\t\t\t\t\t\tavatar: ui.item.avatar,\n\t\t\t\t\t\tname: ui.item.label\n\t\t\t\t\t});\n\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}).data('autocomplete');\n\n\t\t\tif ($input && $input.length) {\n\t\t\t\t$input._renderItem = function(ul, item) {\n\n\t\t\t\t\tvar template = PODLOVE.contributor_template({\n\t\t\t\t\t\tavatar: item.avatar,\n\t\t\t\t\t\tname: item.label,\n\t\t\t\t\t\tdisplay_delete: false,\n\t\t\t\t\t\tdisplay_data: false,\n\t\t\t\t\t\t\"class\": \"contributor autocomplete\"\n\t\t\t\t\t});\n\n\t\t\t\t\treturn $( \"<li></li>\" )\n\t\t\t\t\t    .data( \"item.autocomplete\", item )\n\t\t\t\t\t    .append( \"<a>\" + template + \"</a>\" )\n\t\t\t\t\t    .appendTo( ul );\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t});\n\n\tPODLOVE.add_contributor = function(options) {\n\n\t\tif (!options.avatar) {\n\t\t\toptions.avatar = 'https://www.gravatar.com/avatar?d=mm';\n\t\t}\n\n\t\t// visually add contributor\n\t\t$(\"#add_contributors_input\").val('');\n\t\t$(\"#contributors\").append(PODLOVE.contributor_template(options));\n\n\t\t// actually add contributor\n\t\tvar $data_field = $(\"#tax-input-podlove-contributors\");\n\t\tvar contributors = $data_field.val();\n\n\t\tif (!contributors.length) {\n\t\t\tcontributors = options.slug;\n\t\t} else {\n\t\t\tcontributors = contributors + \",\" + options.slug;\t\n\t\t}\n\t\t\n\t\t$data_field.val(contributors);\n\t}\n\n\tPODLOVE.contributor_template = function(options) {\n\n\t\tvar defaults = {\n\t\t\t\"display_data\": true,\n\t\t\t\"display_delete\": true,\n\t\t\t\"class\": \"contributor\"\n\t\t};\n\t\tvar options = $.extend({}, defaults, options); \n\n\t\tvar tpl = '';\n\t\tif (options.display_data) {\n\t\t\ttpl += '<div class=\"' + options[\"class\"] + '\" data-term-slug=\"' + options.slug + '\" data-term-id=\"' + options.id + '\">';\n\t\t} else {\n\t\t\ttpl += '<div class=\"' + options[\"class\"] + '\">';\n\t\t}\n\t\ttpl += '\t<span>';\n\t\tif (options.display_delete) {\n\t\t\ttpl += '\t\t<a href=\"#\" class=\"ntdelbutton\">x</a>';\n\t\t}\n\t\ttpl += '\t\t<div class=\"avatar\">';\n\t\ttpl += '\t\t\t<img src=\"' + options.avatar + '\" class=\"avatar avatar-24 photo\" height=\"24\" width=\"24\">';\n\t\ttpl += '\t\t</div>';\n\t\ttpl += '\t\t<div class=\"name\">';\n\t\ttpl += '\t\t\t' + options.name;\n\t\ttpl += '\t\t</div>';\n\t\ttpl += '\t</span>';\n\t\ttpl += '</div>';\n\n\t\treturn tpl;\n\t}\n\n}(jQuery));\n"
  },
  {
    "path": "lib/modules/contributors/model/contribution_gender_statistics.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Episode;\n\n/**\n * Gender Statistics for Episode Contributions.\n *\n * Usage:\n *\n *   $stats = (new ContributionGenderStatistics)->get();\n */\nclass ContributionGenderStatistics\n{\n    public $contributions;\n\n    public function __construct()\n    {\n        global $wpdb;\n\n        $sql = \"\n\t\tSELECT\n\t\t\tec.id, ec.role_id, ec.group_id, \n\t\t\t(case when c.gender = 'male' then 1 else 0 end)                  AS male,\n\t\t\t(case when c.gender = 'female' then 1 else 0 end)                AS female,\n\t\t\t(case when c.gender NOT IN ('male', 'female') then 1 else 0 end) AS notattributed\n\t\tFROM\n\t\t\t`\".EpisodeContribution::table_name().'` ec\n\t\t\tJOIN `'.Episode::table_name().'` e     ON e.id = ec.episode_id\n\t\t\tJOIN `'.$wpdb->posts.\"` p              ON p.ID = e.`post_id` AND p.post_status IN ('publish', 'private')\n\t\t\tJOIN `\".Contributor::table_name().'` c ON ec.`contributor_id` = c.id\n\t\t';\n\n        $this->contributions = $wpdb->get_results($sql);\n    }\n\n    public function get()\n    {\n        $genders = ['global' => self::count_contributions($this->contributions)];\n\n        $genders['by_role'] = [];\n        foreach ($this->role_ids() as $role_id) {\n            $genders['by_role'][$role_id] = self::count_contributions($this->filter_by('role_id', $role_id));\n        }\n\n        $genders['by_group'] = [];\n        foreach ($this->group_ids() as $group_id) {\n            $genders['by_group'][$group_id] = self::count_contributions($this->filter_by('group_id', $group_id));\n        }\n\n        return $genders;\n    }\n\n    private function filter_by($filter_key, $filter_value)\n    {\n        return array_filter($this->contributions, function ($c) use ($filter_key, $filter_value) {\n            return $c->{$filter_key} == $filter_value;\n        });\n    }\n\n    private function role_ids()\n    {\n        return array_reduce($this->contributions, function ($agg, $c) {\n            if (!in_array($c->role_id, $agg)) {\n                $agg[] = $c->role_id;\n            }\n\n            return $agg;\n        }, []);\n    }\n\n    private function group_ids()\n    {\n        return array_reduce($this->contributions, function ($agg, $c) {\n            if (!in_array($c->group_id, $agg)) {\n                $agg[] = $c->group_id;\n            }\n\n            return $agg;\n        }, []);\n    }\n\n    private static function count_contributions($contributions)\n    {\n        return [\n            'by_gender' => [\n                'male' => array_reduce($contributions, function ($agg, $c) {\n                    return $agg + $c->male;\n                }, 0),\n                'female' => array_reduce($contributions, function ($agg, $c) {\n                    return $agg + $c->female;\n                }, 0),\n                'none' => array_reduce($contributions, function ($agg, $c) {\n                    return $agg + $c->notattributed;\n                }, 0),\n            ],\n            'total' => count($contributions),\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/model/contributor.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Image;\n\nclass Contributor extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function byGroup($groupSlug)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tcontributor_id\n\t\t\tFROM\n\t\t\t\t'.EpisodeContribution::table_name().'\n\t\t\tWHERE\n\t\t\t\tgroup_id = (SELECT id FROM '.ContributorGroup::table_name().' WHERE slug = %s)\n\t\t\tGROUP BY\n\t\t\t\tcontributor_id\n\t\t';\n\n        $contributor_ids = $wpdb->get_col(\n            $wpdb->prepare($sql, $groupSlug)\n        );\n\n        if (is_array($contributor_ids) && count($contributor_ids) > 0) {\n            return Contributor::all('WHERE id IN ('.implode(',', $contributor_ids).')');\n        }\n\n        return [];\n    }\n\n    public static function byRole($roleSlug)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tcontributor_id\n\t\t\tFROM\n\t\t\t\t'.EpisodeContribution::table_name().'\n\t\t\tWHERE\n\t\t\t\trole_id = (SELECT id FROM '.ContributorRole::table_name().' WHERE slug = %s)\n\t\t\tGROUP BY\n\t\t\t\tcontributor_id\n\t\t';\n\n        $contributor_ids = $wpdb->get_col(\n            $wpdb->prepare($sql, $roleSlug)\n        );\n\n        if (is_array($contributor_ids) && count($contributor_ids) > 0) {\n            return Contributor::all('WHERE id IN ('.implode(',', $contributor_ids).')');\n        }\n\n        return [];\n    }\n\n    public static function byGroupAndRole($groupSlug = null, $roleSlug = null)\n    {\n        global $wpdb;\n\n        if (!$groupSlug && !$roleSlug) {\n            return self::all();\n        }\n\n        if ($groupSlug && !$roleSlug) {\n            return self::byGroup($groupSlug);\n        }\n\n        if (!$groupSlug && $roleSlug) {\n            return self::byRole($roleSlug);\n        }\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\tcontributor_id\n\t\t\tFROM\n\t\t\t\t'.EpisodeContribution::table_name().'\n\t\t\tWHERE\n\t\t\t\trole_id = (SELECT id FROM '.ContributorRole::table_name().' WHERE slug = %s)\n\t\t\t\tAND\n\t\t\t\tgroup_id = (SELECT id FROM '.ContributorGroup::table_name().' WHERE slug = %s)\n\t\t\tGROUP BY\n\t\t\t\tcontributor_id\n\t\t';\n\n        $contributor_ids = $wpdb->get_col(\n            $wpdb->prepare($sql, $roleSlug, $groupSlug)\n        );\n\n        if (is_array($contributor_ids) && count($contributor_ids) > 0) {\n            return Contributor::all('WHERE id IN ('.implode(',', $contributor_ids).')');\n        }\n\n        return [];\n    }\n\n    public function getName()\n    {\n        if ($this->publicname) {\n            return $this->publicname;\n        }\n        if ($this->realname) {\n            return $this->realname;\n        }\n\n        return $this->nickname;\n    }\n\n    public function avatar()\n    {\n        if ($this->avatar) {\n            if (filter_var($this->avatar, FILTER_VALIDATE_EMAIL) === false) {\n                $url = $this->avatar;\n            } else {\n                $url = $this->getGravatarUrl(512, $this->avatar);\n            }\n        } else {\n            $default_url = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJoLTYgdy02IiBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHN0cm9rZT0iY3VycmVudENvbG9yIj4KICA8cGF0aCBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIgZD0iTTUuMTIxIDE3LjgwNEExMy45MzcgMTMuOTM3IDAgMDExMiAxNmMyLjUgMCA0Ljg0Ny42NTUgNi44NzkgMS44MDRNMTUgMTBhMyAzIDAgMTEtNiAwIDMgMyAwIDAxNiAwem02IDJhOSA5IDAgMTEtMTggMCA5IDkgMCAwMTE4IDB6IiAvPgo8L3N2Zz4K';\n            $url = apply_filters('podlove_default_contributor_avatar_url', $default_url);\n        }\n\n        return new Image($url, $this->getName());\n    }\n\n    public function getContributions()\n    {\n        return EpisodeContribution::find_all_by_contributor_id($this->id);\n    }\n\n    /**\n     * Episodes.\n     *\n     * Filter and order episodes with parameters:\n     *\n     * - group: Filter by contribution group. Default: ''.\n     * - role: Filter by contribution role. Default: ''.\n     * - post_status: Publication status of the post. Defaults to 'publish'\n     * - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'.\n     *   - 'publicationDate' - Order by publication date.\n     *   - 'recordingDate' - Order by recording date.\n     *   - 'title' - Order by title.\n     *   - 'slug' - Order by episode slug.\n     *\t - 'limit' - Limit the number of returned episodes.\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        return $this->with_blog_scope(function () use ($args) {\n            global $wpdb;\n\n            $joins = '';\n\n            if (isset($args['group']) && $args['group']) {\n                $joins .= 'INNER JOIN '.ContributorGroup::table_name().\" g ON g.id = ec.group_id AND g.slug = '\".esc_sql($args['group']).\"'\";\n            }\n\n            if (isset($args['role']) && $args['role']) {\n                $joins .= 'INNER JOIN '.ContributorRole::table_name().\" r ON r.id = ec.role_id AND r.slug = '\".esc_sql($args['role']).\"'\";\n            }\n\n            $where = 'ec.contributor_id = '.(int) $this->id;\n\n            if (isset($args['post_status']) && in_array($args['post_status'], get_post_stati())) {\n                $where .= \" AND p.post_status = '\".$args['post_status'].\"'\";\n            } else {\n                $where .= \" AND p.post_status = 'publish'\";\n            }\n\n            // order\n            $order_map = [\n                'publicationDate' => 'p.post_date',\n                'recordingDate' => 'e.recordingDate',\n                'slug' => 'e.slug',\n                'title' => 'p.post_title',\n            ];\n\n            if (isset($args['orderby'], $order_map[$args['orderby']])) {\n                $orderby = $order_map[$args['orderby']];\n            } else {\n                $orderby = $order_map['publicationDate'];\n            }\n\n            if (isset($args['order'])) {\n                $args['order'] = strtoupper($args['order']);\n                if (in_array($args['order'], ['ASC', 'DESC'])) {\n                    $order = $args['order'];\n                } else {\n                    $order = 'DESC';\n                }\n            } else {\n                $order = 'DESC';\n            }\n\n            if (isset($args['limit'])) {\n                $limit = ' LIMIT '.(int) $args['limit'];\n            } else {\n                $limit = '';\n            }\n\n            $sql = '\n\t\t\t\tSELECT\n\t\t\t\t\tec.episode_id\n\t\t\t\tFROM\n\t\t\t\t\t'.EpisodeContribution::table_name().' ec\n\t\t\t\t\tINNER JOIN '.\\Podlove\\Model\\Episode::table_name().' e ON e.id = ec.episode_id\n\t\t\t\t\tINNER JOIN '.$wpdb->posts.' p ON p.ID = e.post_id\n\t\t\t\t\t'.$joins.'\n\t\t\t\tWHERE '.$where.'\n\t\t\t\tGROUP BY ec.episode_id\n\t\t\t\tORDER BY '.$orderby.' '.$order\n                .$limit;\n\n            $episode_ids = $wpdb->get_col($sql);\n\n            return array_map(function ($episode_id) {\n                return \\Podlove\\Model\\Episode::find_one_by_id($episode_id);\n            }, array_unique($episode_ids));\n        });\n    }\n\n    public function getPublishedContributionCount()\n    {\n        global $wpdb;\n\n        $sql = '\n        SELECT count(*) FROM (\n            SELECT\n\t\t\t\te.id\n\t\t\tFROM\n\t\t\t\t'.EpisodeContribution::table_name().' ec\n\t\t\t\tJOIN '.Episode::table_name().' e ON ec.episode_id = e.id\n\t\t\t\tJOIN '.$wpdb->posts.\" p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tec.contributor_id = %d\n\t\t\t\tAND p.post_status = 'publish'\n            GROUP BY\n                e.id) tmp\n\t\t\";\n\n        $contributionCount = $wpdb->get_var(\n            $wpdb->prepare($sql, $this->id)\n        );\n\n        return $contributionCount;\n    }\n\n    public function getShowContributions()\n    {\n        return ShowContribution::find_all_by_contributor_id($this->id);\n    }\n\n    public function getDefaultContributions()\n    {\n        return DefaultContribution::find_all_by_contributor_id($this->id);\n    }\n\n    /**\n     * Calculates episode contributions and stores them in contributioncount attribute.\n     */\n    public function calcContributioncount()\n    {\n        $this->contributioncount = $this->getPublishedContributionCount();\n        $this->save();\n    }\n\n    /**\n     * Return private mail address in RFC2822 format.\n     *\n     * Something like:\n     *\n     *   John Doe <hello@doe.com>\n     *\n     * @return string\n     */\n    public function getMailAddress()\n    {\n        $name = $this->getName();\n        $email = $this->privateemail;\n\n        if (empty($email)) {\n            return '';\n        }\n\n        if (empty($name)) {\n            return $email;\n        }\n\n        return sprintf('%s <%s>', trim($name), trim($email));\n    }\n\n    /**\n     * @override \\Podlove\\Model\\Base::delete();\n     */\n    public function delete()\n    {\n        foreach ($this->getContributions() as $contribution) {\n            $contribution->delete();\n        }\n\n        foreach ($this->getShowContributions() as $contribution) {\n            $contribution->delete();\n        }\n\n        foreach ($this->getDefaultContributions() as $contribution) {\n            $contribution->delete();\n        }\n\n        parent::delete();\n    }\n\n    /**\n     * Get Gravatar URL for a specified email address.\n     *\n     * Yes, I know there is get_avatar() but that returns the img tag and I need the URL.\n     *\n     * @param string     $s     Size in pixels, defaults to 80px [ 1 - 2048 ]\n     * @param null|mixed $email\n     *\n     * @source http://gravatar.com/site/implement/images/php/\n     */\n    private function getGravatarUrl($s = 80, $email = null)\n    {\n        $email = $email ? $email : $this->publicemail;\n\n        $url = 'https://www.gravatar.com/avatar/';\n        $url .= md5(strtolower(trim($email)));\n        $url .= '.jpg';\n        $url .= \"?s={$s}&d=mm&r=g\";\n\n        return $url;\n    }\n}\n\nContributor::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nContributor::property('identifier', 'VARCHAR(255)');\nContributor::property('gender', 'VARCHAR(255)');\nContributor::property('organisation', 'TEXT');\nContributor::property('department', 'TEXT');\nContributor::property('jobtitle', 'TEXT');\nContributor::property('avatar', 'TEXT');\nContributor::property('flattr', 'VARCHAR(255)');\nContributor::property('publicemail', 'TEXT');\t\t\t\t// DEPRECATED since 1.10.23\nContributor::property('privateemail', 'TEXT');\nContributor::property('realname', 'TEXT');\nContributor::property('nickname', 'TEXT');\nContributor::property('publicname', 'TEXT');\nContributor::property('visibility', 'TINYINT(1)');\nContributor::property('guid', 'TEXT');\nContributor::property('contributioncount', 'INT');\n"
  },
  {
    "path": "lib/modules/contributors/model/contributor_group.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\n\nclass ContributorGroup extends Base\n{\n    public static function selectOptions()\n    {\n        $list = [];\n        foreach (self::all() as $role) {\n            $list[$role->slug] = $role->title;\n        }\n\n        return $list;\n    }\n}\n\nContributorGroup::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nContributorGroup::property('slug', 'VARCHAR(255)');\nContributorGroup::property('title', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/modules/contributors/model/contributor_role.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\n\nclass ContributorRole extends Base\n{\n    public static function selectOptions()\n    {\n        $list = [];\n        foreach (self::all() as $role) {\n            $list[$role->slug] = $role->title;\n        }\n\n        return $list;\n    }\n}\n\nContributorRole::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nContributorRole::property('slug', 'VARCHAR(255)');\nContributorRole::property('title', 'VARCHAR(255)');\n"
  },
  {
    "path": "lib/modules/contributors/model/default_contribution.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\n\n/**\n * A contributor contributes to a podcast/show.\n */\nclass DefaultContribution extends Base\n{\n    public function getRole()\n    {\n        return ContributorRole::find_by_id($this->role_id);\n    }\n\n    public function getGroup()\n    {\n        return ContributorGroup::find_by_id($this->group_id);\n    }\n\n    public function getContributor()\n    {\n        return Contributor::find_by_id($this->contributor_id);\n    }\n\n    public function hasRole()\n    {\n        return ((int) $this->role_id) > 0;\n    }\n\n    public function hasGroup()\n    {\n        return ((int) $this->group_id) > 0;\n    }\n}\n\nDefaultContribution::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nDefaultContribution::property('contributor_id', 'INT');\nDefaultContribution::property('show_id', 'INT');\nDefaultContribution::property('role_id', 'INT');\nDefaultContribution::property('group_id', 'INT');\nDefaultContribution::property('position', 'FLOAT');\nDefaultContribution::property('comment', 'TEXT');\n"
  },
  {
    "path": "lib/modules/contributors/model/episode_contribution.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\n\n/**\n * A contributor contributes to an episode.\n */\nclass EpisodeContribution extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function getRole()\n    {\n        return $this->with_blog_scope(function () {\n            return ContributorRole::find_by_id($this->role_id);\n        });\n    }\n\n    public function getGroup()\n    {\n        return $this->with_blog_scope(function () {\n            return ContributorGroup::find_by_id($this->group_id);\n        });\n    }\n\n    public function getContributor()\n    {\n        return Contributor::find_by_id($this->contributor_id);\n    }\n\n    public function hasRole()\n    {\n        return ((int) $this->role_id) > 0;\n    }\n\n    public function hasGroup()\n    {\n        return ((int) $this->group_id) > 0;\n    }\n\n    public function getEpisode()\n    {\n        return \\Podlove\\Model\\Episode::find_one_by_id($this->episode_id);\n    }\n\n    public function save()\n    {\n        parent::save();\n        if ($contributor = $this->getContributor()) {\n            $contributor->calcContributioncount();\n        }\n    }\n\n    public function delete()\n    {\n        parent::delete();\n        if ($contributor = $this->getContributor()) {\n            $contributor->calcContributioncount();\n        }\n    }\n\n    public static function sortByComment($a, $b)\n    {\n        return strcmp($a->comment, $b->comment);\n    }\n\n    public static function sortByPosition($a, $b)\n    {\n        if ($a->position == $b->position) {\n            return 0;\n        }\n\n        return ($a->position < $b->position) ? -1 : 1;\n    }\n}\n\nEpisodeContribution::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nEpisodeContribution::property('contributor_id', 'INT');\nEpisodeContribution::property('episode_id', 'INT');\nEpisodeContribution::property('role_id', 'INT');\nEpisodeContribution::property('group_id', 'INT');\nEpisodeContribution::property('position', 'FLOAT');\nEpisodeContribution::property('comment', 'TEXT');\n"
  },
  {
    "path": "lib/modules/contributors/model/show_contribution.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Model;\n\nuse Podlove\\Model\\Base;\n\n/**\n * A contributor contributes to a podcast/show.\n */\nclass ShowContribution extends Base\n{\n    public function getRole()\n    {\n        return ContributorRole::find_by_id($this->role_id);\n    }\n\n    public function getGroup()\n    {\n        return ContributorGroup::find_by_id($this->group_id);\n    }\n\n    public function getContributor()\n    {\n        return Contributor::find_by_id($this->contributor_id);\n    }\n\n    public function hasRole()\n    {\n        return ((int) $this->role_id) > 0;\n    }\n\n    public function hasGroup()\n    {\n        return ((int) $this->group_id) > 0;\n    }\n}\n\nShowContribution::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nShowContribution::property('contributor_id', 'INT');\nShowContribution::property('show_id', 'INT');\nShowContribution::property('role_id', 'INT');\nShowContribution::property('group_id', 'INT');\nShowContribution::property('position', 'FLOAT');\nShowContribution::property('comment', 'TEXT');\n"
  },
  {
    "path": "lib/modules/contributors/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\DefaultContribution;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v1';\n    public const api_base = 'contributors';\n\n    // todo: delete\n    // todo: create\n    // todo: update\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contributors'],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/groups', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_groups'],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/roles', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_roles'],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for contributor.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contributor'],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/episode/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for episode.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_episode'],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n    }\n\n    public function get_contributors()\n    {\n        $entries = Contributor::all();\n        $entries = array_reduce($entries, function ($result, $contributor) {\n            if ($contributor->visibility != 1) {\n                return $result;\n            }\n\n            array_push($result, $this->filter_contributor($contributor));\n\n            return $result;\n        }, []);\n\n        return new \\WP_REST_Response($entries);\n    }\n\n    public function get_groups()\n    {\n        $groups = ContributorGroup::all();\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $groups);\n\n        return new \\WP_REST_Response($entries);\n    }\n\n    public function get_roles()\n    {\n        $roles = ContributorRole::all();\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $roles);\n\n        return new \\WP_REST_Response($entries);\n    }\n\n    public function get_contributor($request)\n    {\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n\n        if (!isset($contributor)) {\n            return new \\WP_Error(\n                'podlove_rest_contributor_not_found',\n                'contributor not found',\n                ['status' => 404]\n            );\n        }\n\n        return new \\WP_REST_Response($this->filter_contributor($contributor));\n    }\n\n    public function get_episode($request)\n    {\n        $id = $request->get_param('id');\n\n        $results = array_map(function ($contributor) {\n            return [\n                'id' => $contributor->contributor_id,\n                'role' => $contributor->role_id,\n                'group' => $contributor->group_id,\n            ];\n        }, EpisodeContribution::find_all_by_episode_id($id));\n\n        return new \\WP_REST_Response($results);\n    }\n\n    private function filter_contributor($contributor)\n    {\n        return [\n            'id' => $contributor->id,\n            'slug' => $contributor->identifier,\n            'avatar' => $contributor->avatar,\n            'name' => $contributor->getName(),\n            'mail' => $contributor->publicemail,\n            'department' => $contributor->department,\n            'organisation' => $contributor->organisation,\n            'jobtitle' => $contributor->jobtitle,\n            'gender' => $contributor->gender,\n            'nickname' => $contributor->nickname,\n            'count' => $contributor->contributioncount,\n        ];\n    }\n}\n\nclass WP_REST_PodloveContributors_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'contributors';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, $this->rest_base, [\n            [\n                'args' => [\n                    'filter' => [\n                        'description' => __('The filter parameter is used to filter the collection of contributors.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['all', 'visible']\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for contributor.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'gender' => [\n                        'description' => __('Gender of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['female', 'male', 'Not attributed']\n                    ],\n                    'visibility' => [\n                        'description' => __('Should the participation of the contributor be publicily visible?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'enum' => ['yes', 'no']\n                    ],\n                    'identifier' => [\n                        'description' => __('identifier', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'organisation' => [\n                        'description' => __('Organisation', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'department' => [\n                        'description' => __('Department', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'jobtitle' => [\n                        'description' => __('Jobtitle of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'realname' => [\n                        'description' => __('Name of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'nickname' => [\n                        'description' => __('Nickname of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'publicname' => [\n                        'description' => __('Used name in the blog', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'avatar' => [\n                        'description' => __('Avatar of the contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::url'\n                    ],\n                    'email' => [\n                        'description' => __('e-mail of the contributor Do not use external.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'format' => 'email',\n                    ],\n                ],\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/groups', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items_group'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item_group'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/groups/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for contributor group.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_group'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_group'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n                'args' => [\n                    'title' => [\n                        'description' => __('Title of the contributor group', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::maxLength255'\n                    ],\n                    'slug' => [\n                        'description' => __('Slug of the contributor group', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::maxLength255'\n                    ],\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item_group'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/roles', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items_role'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item_role'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/roles/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for contributor role.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_role'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_role'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n                'args' => [\n                    'title' => [\n                        'description' => __('Title of the contributor role', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::maxLength255'\n                    ],\n                    'slug' => [\n                        'description' => __('Slug of the contributor role', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::maxLength255'\n                    ],\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item_role'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/defaults', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items_default'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item_default'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/defaults/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for defaults contributor.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_default'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_default'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n                'args' => [\n                    'contributor_id' => [\n                        'description' => __('Contributor ID of the default contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                        'required' => 'true',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::isContributorIdExist'\n                    ],\n                    'show_id' => [\n                        'description' => __('Show ID of the default contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                        'required' => 'true',\n                    ],\n                    'group_id' => [\n                        'description' => __('Group ID of the default contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::isContributorGroupIdExist'\n                    ],\n                    'role_id' => [\n                        'description' => __('Role ID of the contributor group', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::isContributorRoleIdExist'\n                    ],\n                    'position' => [\n                        'description' => __('Position of the default contributor in the list', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'comment' => [\n                        'description' => __('Comment to the default contributor', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item_default'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ],\n        ]);\n        register_rest_route($this->namespace, $this->rest_base.'/(?P<id>[\\d]+)/episodes', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for contributor.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_episodes'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n    }\n\n    public function get_items($request)\n    {\n        $filter = $request->get_param('filter');\n        if (!$filter || $filter != 'all') {\n            $filter = 'visible';\n        }\n\n        $entries = Contributor::all();\n\n        $result = [];\n        for ($i = 0; $i < count($entries); ++$i) {\n            if ($filter == 'visible') {\n                if ($entries[$i]->visibility == 1) {\n                    array_push($result, $this->get_contributor_data($entries[$i]));\n                }\n            } else {\n                if ($filter == 'all') {\n                    array_push($result, $this->get_contributor_data_full($entries[$i]));\n                }\n            }\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'contributors' => $result\n        ]);\n    }\n\n    public function get_items_group($request)\n    {\n        $groups = ContributorGroup::all();\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $groups);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'groups' => $entries\n        ]);\n    }\n\n    public function get_item_group($request)\n    {\n        $id = $request->get_param('id');\n        $group = ContributorGroup::find_by_id($id);\n\n        if (!isset($group)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'id' => $group->id,\n            'title' => $group->title,\n            'slug' => $group->slug\n        ]);\n    }\n\n    public function get_items_role($request)\n    {\n        $roles = ContributorRole::all();\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $roles);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'roles' => $entries\n        ]);\n    }\n\n    public function get_item_role($request)\n    {\n        $id = $request->get_param('id');\n        $role = ContributorRole::find_by_id($id);\n\n        if (!isset($role)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'id' => $role->id,\n            'title' => $role->title,\n            'slug' => $role->slug\n        ]);\n    }\n\n    public function get_items_default($request)\n    {\n        $defaults = DefaultContribution::all();\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $defaults);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'defaults' => $entries\n        ]);\n    }\n\n    public function get_item_default($request)\n    {\n        $id = $request->get_param('id');\n        $default = DefaultContribution::find_by_id($id);\n\n        if (!isset($default)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'id' => $default->id,\n            'contributor_id' => $default->contributor_id,\n            'show_id' => $default->show_id,\n            'group_id' => $default->group_id,\n            'role_id' => $default->role_id,\n            'position' => $default->position,\n            'comment' => $default->comment\n        ]);\n    }\n\n    public function get_item($request)\n    {\n        $filter = $request->get_param('filter');\n        if (!$filter || $filter != 'all') {\n            $filter = 'visible';\n        }\n\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n\n        if (!isset($contributor)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if ($filter == 'visible') {\n            if ($contributor->visibility != 1) {\n                return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n            }\n        }\n\n        if ($filter == 'all') {\n            $result = $this->get_contributor_data_full($contributor);\n        } else {\n            $result = $this->get_contributor_data($contributor);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'contributor' => $result\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        $filter = $request->get_param('filter');\n        if ($filter) {\n            if ($filter == 'all') {\n                if (!current_user_can('edit_posts')) {\n                    return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n                }\n\n                return true;\n            }\n\n            return true;\n        }\n\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        $contributor = new Contributor();\n        $contributor->visibility = 1;\n        $contributor->contributioncount = 0;\n        $contributor->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $contributor->id\n        ]);\n    }\n\n    public function create_item_group($request)\n    {\n        $group = new ContributorGroup();\n        $group->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $group->id\n        ]);\n    }\n\n    public function create_item_role($request)\n    {\n        $role = new ContributorRole();\n        $role->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $role->id\n        ]);\n    }\n\n    public function create_item_default($request)\n    {\n        $default = new DefaultContribution();\n        $default->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $default->id\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n\n        if (!isset($contributor)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['gender'])) {\n            $gender = $request['gender'];\n            if ($gender === 'Not attributed') {\n                $contributor->gender = 'none';\n            } else {\n                $contributor->gender = $gender;\n            }\n        }\n\n        if (isset($request['identifier'])) {\n            $identifier = $request['identifier'];\n            $contributor->identifier = $identifier;\n        }\n\n        if (isset($request['organisation'])) {\n            $organisation = $request['organisation'];\n            $contributor->organisation = $organisation;\n        }\n\n        if (isset($request['department'])) {\n            $department = $request['department'];\n            $contributor->department = $department;\n        }\n\n        if (isset($request['jobtitle'])) {\n            $jobtitle = $request['jobtitle'];\n            $contributor->jobtitle = $jobtitle;\n        }\n\n        if (isset($request['realname'])) {\n            $realname = $request['realname'];\n            $contributor->realname = $realname;\n        }\n\n        if (isset($request['nickname'])) {\n            $nickname = $request['nickname'];\n            $contributor->nickname = $nickname;\n        }\n\n        if (isset($request['publicname'])) {\n            $publicname = $request['publicname'];\n            $contributor->publicname = $publicname;\n        }\n\n        if (isset($request['avatar'])) {\n            $avatar = $request['avatar'];\n            $contributor->avatar = $avatar;\n        }\n\n        if (isset($request['visibility'])) {\n            $visibility = $request['visibility'];\n            if ($visibility == 'no') {\n                $contributor->visibility = 0;\n            }\n            if ($visibility == 'yes') {\n                $contributor->visibility = 1;\n            }\n        }\n\n        if (isset($request['email'])) {\n            $privateemail = $request['email'];\n            $contributor->privateemail = $privateemail;\n        }\n\n        $contributor->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_group($request)\n    {\n        $id = $request->get_param('id');\n        $group = ContributorGroup::find_by_id($id);\n\n        if (!isset($group)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $group->title = $title;\n        }\n\n        if (isset($request['slug'])) {\n            $slug = $request['slug'];\n            $group->slug = $slug;\n        }\n\n        $group->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_role($request)\n    {\n        $id = $request->get_param('id');\n        $role = ContributorRole::find_by_id($id);\n\n        if (!isset($role)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $role->title = $title;\n        }\n\n        if (isset($request['slug'])) {\n            $slug = $request['slug'];\n            $role->slug = $slug;\n        }\n\n        $role->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_default($request)\n    {\n        $id = $request->get_param('id');\n        $default = DefaultContribution::find_by_id($id);\n\n        if (!isset($default)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['contributor_id'])) {\n            $contributor_id = $request['contributor_id'];\n            $default->contributor_id = $contributor_id;\n        }\n\n        if (isset($request['group_id'])) {\n            $group_id = $request['group_id'];\n            $default->group_id = $group_id;\n        }\n\n        if (isset($request['role_id'])) {\n            $role_id = $request['role_id'];\n            $default->role_id = $role_id;\n        }\n\n        if (isset($request['position'])) {\n            $position = $request['position'];\n            $default->position = $position;\n        }\n\n        if (isset($request['comment'])) {\n            $comment = $request['comment'];\n            $default->comment = $comment;\n        }\n\n        $default->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n\n        if (!isset($contributor)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $contributor->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_group($request)\n    {\n        $id = $request->get_param('id');\n        $group = ContributorGroup::find_by_id($id);\n\n        if (!isset($group)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $group->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_role($request)\n    {\n        $id = $request->get_param('id');\n        $role = ContributorRole::find_by_id($id);\n\n        if (!isset($role)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $role->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_default($request)\n    {\n        $id = $request->get_param('id');\n        $default = DefaultContribution::find_by_id($id);\n\n        if (!isset($default)) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $default->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_episodes($request)\n    {\n        $id = $request->get_param('id');\n\n        $results = array_map(function ($contributor) {\n            return [\n                'epsiode_id' => $contributor->episode_id\n            ];\n        }, EpisodeContribution::find_all_by_contributor_id($id));\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'episodes' => $results\n        ]);\n    }\n\n    private function get_contributor_data($contributor)\n    {\n        return [\n            'id' => $contributor->id,\n            'identifier' => $contributor->identifier,\n            'avatar_url' => $contributor->avatar()->url(),\n            'name' => $contributor->getName(),\n            'mail' => $contributor->publicemail,\n            'department' => $contributor->department,\n            'organisation' => $contributor->organisation,\n            'jobtitle' => $contributor->jobtitle,\n            'gender' => $contributor->gender,\n            'nickname' => $contributor->nickname,\n            'count' => $contributor->contributioncount,\n        ];\n    }\n\n    private function get_contributor_data_full($contributor)\n    {\n        return [\n            'id' => $contributor->id,\n            'identifier' => $contributor->identifier,\n            'visibility' => $contributor->visibility,\n            'avatar' => $contributor->avatar,\n            'avatar_url' => $contributor->avatar()->url(),\n            'publicname' => $contributor->publicname,\n            'nickname' => $contributor->nickname,\n            'realname' => $contributor->realname,\n            'mail' => $contributor->publicemail,\n            'department' => $contributor->department,\n            'organisation' => $contributor->organisation,\n            'jobtitle' => $contributor->jobtitle,\n            'gender' => $contributor->gender,\n            'count' => $contributor->contributioncount,\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/contributor_defaults.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings;\n\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Model\\DefaultContribution;\n\nclass ContributorDefaults\n{\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['podlove_contributor_defaults'])) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        if ($action === 'save') {\n            $this->save_setting();\n        }\n    }\n\n    public function save_setting()\n    {\n        $contributor_appearances = $_REQUEST['podlove_contributor_defaults']['contributor'];\n\n        foreach (DefaultContribution::all() as $contribution) {\n            $contribution->delete();\n        }\n\n        $position = 0;\n        foreach ($contributor_appearances as $contributor_appearance) {\n            foreach ($contributor_appearance as $contributor_id => $contributor) {\n                $c = new DefaultContribution();\n\n                if (isset($contributor['role']) && ($role = ContributorRole::find_one_by_slug($contributor['role']))) {\n                    $c->role_id = $role->id;\n                }\n\n                if (isset($contributor['group']) && ($group = ContributorGroup::find_one_by_slug($contributor['group']))) {\n                    $c->group_id = $group->id;\n                }\n\n                $c->contributor_id = $contributor_id;\n\n                if (isset($contributor['comment'])) {\n                    $c->comment = $contributor['comment'];\n                }\n\n                $c->position = $position++;\n                $c->save();\n            }\n        }\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<p>\n\t\t\t\t<?php _e('Default Contributors will be automatically added to the list of contributors for new episodes.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<form method=\"post\" action=\"admin.php?page=podlove_contributor_settings&amp;action=save\" id=\"contributor_default_form\">\n\t\t\t<?php\n                $this->default_contrib_form(); ?>\n\t\t\t<p>\n\t\t\t\t<input type=\"submit\" name=\"submit\" id=\"submit\" class=\"button button-primary\" value=\"<?php _e('Save Changes', 'podlove-podcasting-plugin-for-wordpress'); ?>\"  />\n\t\t\t</p>\n\t\t\t</form>\n\t\t</div>\t\n\t\t<?php\n    }\n\n    private function default_contrib_form()\n    {\n        $contributions = DefaultContribution::all();\n\n        // map indices to IDs\n        $map = [];\n        foreach ($contributions as $c) {\n            $map[$c->id] = $c;\n        }\n\n        \\Podlove\\Modules\\Contributors\\Contributors::contributors_form_table($map, 'podlove_contributor_defaults[contributor]');\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/contributor_settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings;\n\nuse Podlove\\Settings\\Expert\\Tabs;\n\nclass ContributorSettings\n{\n    public static $pagehook;\n\n    public $tabs;\n\n    public function __construct($handle)\n    {\n        ContributorSettings::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Contributors', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Contributors', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'podlove_manage_contributors',\n            // $menu_slug\n            'podlove_contributor_settings',\n            // $function\n            [$this, 'page']\n        );\n\n        $is_settings_page = filter_input(INPUT_GET, 'page') == 'podlove_contributor_settings';\n        $is_settings_update_request = filter_input(INPUT_POST, 'option_page') == ContributorSettings::$pagehook;\n\n        if ($is_settings_page || $is_settings_update_request) {\n            $tabs = new Tabs(__('Contributors', 'podlove-podcasting-plugin-for-wordpress'));\n            $tabs->addTab(new \\Podlove\\Modules\\Contributors\\Settings\\Tab\\Contributors(__('Contributors', 'podlove-podcasting-plugin-for-wordpress'), true));\n            $tabs->addTab(new \\Podlove\\Modules\\Contributors\\Settings\\Tab\\Groups(__('Groups', 'podlove-podcasting-plugin-for-wordpress')));\n            $tabs->addTab(new \\Podlove\\Modules\\Contributors\\Settings\\Tab\\Roles(__('Roles', 'podlove-podcasting-plugin-for-wordpress')));\n            $tabs->addTab(new \\Podlove\\Modules\\Contributors\\Settings\\Tab\\Defaults(__('Defaults', 'podlove-podcasting-plugin-for-wordpress')));\n\n            $tabs = apply_filters('podlove_contributor_settings_tabs', $tabs);\n\n            $this->tabs = $tabs;\n            $this->tabs->initCurrentTab();\n\n            foreach ($this->tabs->getTabs() as $tab) {\n                if (method_exists($tab, 'getObject')) {\n                    add_action('admin_init', [$tab->getObject(), 'process_form']);\n                }\n            }\n        }\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<?php\n            echo $this->tabs->getTabsHTML();\n        echo $this->tabs->getCurrentTabPage(); ?>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/generic_entity_settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings;\n\n/**\n * Provide a standard settings page for an entity with:.\n *\n * 1) list table view\n * 2) edit form per item\n */\nclass GenericEntitySettings\n{\n    private $entity_slug;\n    private $entity_class;\n    private $form_callback;\n    private $labels = [];\n\n    private $is_tab = false;\n    private $tab_slug = '';\n\n    private static $nonce = 'update_podcast_generic_settings';\n\n    public function __construct($entity_slug, $entity_class)\n    {\n        $this->entity_slug = $entity_slug;\n        $this->entity_class = $entity_class;\n\n        $default_labels = [\n            'delete_confirm' => __('You selected to delete the entity \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'),\n            'delete_button_delete' => __('Delete permanently', 'podlove-podcasting-plugin-for-wordpress'),\n            'delete_button_keep' => __('Don\\'t change anything', 'podlove-podcasting-plugin-for-wordpress'),\n            'add_new' => __('Add New', 'podlove-podcasting-plugin-for-wordpress'),\n            'edit' => __('Edit', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        $this->labels = $default_labels;\n\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function enable_tabs($tab_slug)\n    {\n        $this->is_tab = true;\n        $this->tab_slug = $tab_slug;\n    }\n\n    public function set_labels($labels)\n    {\n        $this->labels = wp_parse_args($labels, $this->labels);\n    }\n\n    public function set_form($form_callback)\n    {\n        $this->form_callback = $form_callback;\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST[$this->get_entity_slug()])) {\n            return;\n        }\n\n        $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        }\n    }\n\n    public function page()\n    {\n        if (isset($_GET['action']) and $_GET['action'] == 'confirm_delete' and isset($_REQUEST[$this->get_entity_slug()])) {\n            $class = $this->get_entity_class();\n            $entity = $class::find_by_id($_REQUEST[$this->get_entity_slug()]);\n\n            $title = $entity->title;\n            if (!$title && method_exists($entity, 'getName')) {\n                $title = $entity->getName();\n            } ?>\n\t\t\t<div class=\"updated\">\n\t\t\t\t<p>\n\t\t\t\t\t<strong>\n\t\t\t\t\t\t<?php echo sprintf($this->labels['delete_confirm'], $title); ?>\n\t\t\t\t\t</strong>\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t<?php echo self::get_action_link($this->get_entity_slug(), $entity->id, $this->labels['delete_button_delete'], 'delete', 'button'); ?>\n\t\t\t\t\t<?php echo self::get_action_link($this->get_entity_slug(), $entity->id, $this->labels['delete_button_keep'], 'keep', 'button-primary'); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        } ?>\n\t\t<div class=\"wrap\">\n\t\t\t<?php\n                do_action('podlove_settings_'.$this->entity_slug.'_before');\n\n        if (isset($_GET['action'])) {\n            switch ($_GET['action']) {\n                case 'new':   $this->new_template();\n\n                    break;\n                case 'edit':  $this->edit_template();\n\n                    break;\n\n                default:      $this->view_template();\n\n                    break;\n            }\n        } else {\n            $this->view_template();\n        }\n\n        do_action('podlove_settings_'.$this->entity_slug); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public static function get_action_link($entity_slug, $id, $title, $action = 'edit', $class = 'link')\n    {\n        $podlove_tab = htmlspecialchars($_REQUEST['podlove_tab'] ?? '');\n        $request = $podlove_tab ? '&amp;podlove_tab='.$podlove_tab : '';\n        $page = htmlspecialchars($_REQUEST['page'] ?? '');\n\n        return sprintf(\n            '<a href=\"?page=%s%s&amp;action=%s&amp;%s=%s\" class=\"%s\">'.$title.'</a>',\n            $page,\n            $request,\n            $action,\n            $entity_slug,\n            $id,\n            $class\n        );\n    }\n\n    /**\n     * Process form: save/update entity.\n     */\n    protected function save()\n    {\n        $slug = $this->get_entity_slug();\n\n        if (!isset($_REQUEST[$slug])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $class = $this->get_entity_class();\n\n        $entity = $class::find_by_id($_REQUEST[$slug]);\n\n        $attributes = $_POST['podlove_'.$slug];\n        $attributes = apply_filters('podlove_generic_entity_attributes', $attributes);\n        $attributes = apply_filters('podlove_generic_entity_attributes_'.$slug, $attributes);\n\n        $entity->update_attributes($attributes);\n\n        do_action('podlove_update_entity_'.$slug, $entity);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $entity->id);\n        } else {\n            $this->redirect('index', $entity->id);\n        }\n    }\n\n    /**\n     * Process form: create entity.\n     */\n    protected function create()\n    {\n        $class = $this->get_entity_class();\n\n        $entity = new $class();\n        $entity->update_attributes($_POST['podlove_'.$this->get_entity_slug()]);\n\n        do_action('podlove_create_entity_'.$this->get_entity_slug(), $entity);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $entity->id);\n        } else {\n            $this->redirect('index');\n        }\n    }\n\n    /**\n     * Process form: delete a contributor.\n     */\n    protected function delete()\n    {\n        if (!isset($_REQUEST[$this->get_entity_slug()])) {\n            return;\n        }\n\n        $class = $this->get_entity_class();\n        $class::find_by_id($_REQUEST[$this->get_entity_slug()])->delete();\n\n        $this->redirect('index');\n    }\n\n    protected function new_template()\n    {\n        $class = $this->get_entity_class();\n        $entity = new $class();\n\n        echo '<h3>'.$this->labels['add_new'].'</h3>';\n        do_action('podlove_settings_'.$this->entity_slug.'_new_before');\n        $this->form_template($entity, 'create');\n        do_action('podlove_settings_'.$this->entity_slug.'_new');\n    }\n\n    protected function edit_template()\n    {\n        $class = $this->get_entity_class();\n        $entity = $class::find_by_id($_REQUEST[$this->get_entity_slug()]);\n        echo '<h3>'.$this->labels['edit'].'</h3>';\n        do_action('podlove_settings_'.$this->entity_slug.'_edit_before');\n        $this->form_template($entity, 'save');\n        do_action('podlove_settings_'.$this->entity_slug.'_edit');\n    }\n\n    protected function view_template()\n    {\n        $tab = $this->is_tab ? '&amp;podlove_tab='.$this->tab_slug : '';\n        $page = htmlspecialchars($_REQUEST['page'] ?? ''); ?>\n\t\t<h2>\n\t\t\t<a href=\"?page=<?php echo $page.$tab; ?>&amp;action=new\" class=\"add-new-h2\"><?php echo $this->labels['add_new']; ?></a>\n\t\t</h2>\n\t\t<?php\n        do_action('podlove_settings_'.$this->entity_slug.'_view');\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $entity_id\n     */\n    protected function redirect($action, $entity_id = null)\n    {\n        $page = 'admin.php?page='.htmlspecialchars($_REQUEST['page'] ?? '');\n        $show = $entity_id ? '&'.$this->get_entity_slug().'='.$entity_id : '';\n        $action = '&action='.$action;\n        $tab = $this->is_tab ? '&podlove_tab='.$this->tab_slug : '';\n\n        wp_redirect(admin_url($page.$show.$action.$tab));\n        exit;\n    }\n\n    private function get_entity_slug()\n    {\n        return $this->entity_slug;\n    }\n\n    private function get_entity_class()\n    {\n        return $this->entity_class;\n    }\n\n    private function form_template($entity, $action)\n    {\n        $form_args = [\n            'context' => 'podlove_'.$this->get_entity_slug(),\n            'hidden' => ['action' => $action],\n            'submit_button' => false, // for custom control in form_end\n            'form_end' => function () {\n                echo '<p>';\n                submit_button(__('Save Changes', 'podlove-podcasting-plugin-for-wordpress'), 'primary', 'submit', false);\n                echo ' ';\n                submit_button(__('Save Changes and Continue Editing', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit_and_stay', false);\n                echo '</p>';\n            },\n            'nonce' => self::$nonce\n        ];\n\n        $form_args['hidden'][$this->get_entity_slug()] = $entity->id;\n\n        $cb = $this->form_callback;\n        $cb($form_args, $entity, $action);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/podcast_contributors_settings_tab.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings;\n\nuse Podlove\\Modules\\Contributors\\Model\\ShowContribution;\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass PodcastContributorsSettingsTab extends Tab\n{\n    private static $nonce = 'update_podcast_contributors_team';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = ['contributor'];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_podcast'][$key];\n        }\n        update_option('podlove_podcast', $settings);\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'is_table' => false,\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php echo sprintf(\n\t\t\t    __('This is the current team of your podcast. Display this list using the shortcode %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t    '<code>[podlove-podcast-contributor-list]</code>'\n\t\t\t); ?>\n\t\t</p>\n\t\t<?php\n\n\t\t\t    \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n\t\t\t        $wrapper = new \\Podlove\\Form\\Input\\DivWrapper($form);\n\t\t\t        $podcast = $form->object;\n\n\t\t\t        $wrapper->callback('contributors', [\n\t\t\t            // 'label'    => __( 'Contributors', 'podlove-podcasting-plugin-for-wordpress' ),\n\t\t\t            'callback' => [__CLASS__, 'podcast_form_extension_form'],\n\t\t\t        ]);\n\t\t\t    });\n    }\n\n    public static function podcast_form_extension_form()\n    {\n        $contributions = ShowContribution::all();\n\n        // map indices to IDs\n        $map = [];\n        foreach ($contributions as $c) {\n            $map[$c->id] = $c;\n        }\n\n        \\Podlove\\Modules\\Contributors\\Contributors::contributors_form_table($map, 'podlove_podcast[contributor]');\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/tab/contributors.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings\\Tab;\n\nuse Podlove\\Modules\\Contributors\\Contributor_List_Table;\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass Contributors extends Tab\n{\n    public $object;\n    public $table;\n    private $page;\n\n    public function get_slug()\n    {\n        return 'contributors';\n    }\n\n    public function init()\n    {\n        $this->page_type = 'custom';\n        add_action('podlove_expert_settings_page', [$this, 'register_page']);\n        add_action('load-'.\\Podlove\\Modules\\Contributors\\Settings\\ContributorSettings::$pagehook, [$this, 'add_contributors_screen_options']);\n    }\n\n    public function add_contributors_screen_options()\n    {\n        add_screen_option('per_page', [\n            'label' => __('Contributors', 'podlove-podcasting-plugin-for-wordpress'),\n            'default' => 10,\n            'option' => 'podlove_contributors_per_page',\n        ]);\n\n        $this->table = new Contributor_List_Table();\n    }\n\n    public function register_page()\n    {\n        $this->object = $this->getObject();\n        $this->object->page();\n    }\n\n    public function getObject()\n    {\n        if (!$this->page) {\n            $this->createObject();\n        }\n\n        return $this->page;\n    }\n\n    public function createObject()\n    {\n        $this->page = new \\Podlove\\Modules\\Contributors\\Settings\\GenericEntitySettings(\n            'contributor',\n            '\\Podlove\\Modules\\Contributors\\Model\\Contributor'\n        );\n\n        $this->page->enable_tabs('contributors');\n\n        $this->page->set_form(function ($form_args, $contributor, $action) {\n            $this->contributor_form($form_args, $contributor, $action);\n        });\n\n        add_action('podlove_settings_contributor_view', function () {\n            $this->table->prepare_items();\n            $this->table->display();\n        });\n\n        add_filter('podlove_generic_entity_attributes_contributor', function ($attributes) {\n            $sanitize = function ($var) {\n                return filter_var(stripslashes($var), FILTER_UNSAFE_RAW, ['flags' => FILTER_FLAG_NO_ENCODE_QUOTES]);\n            };\n\n            $attributes['publicname'] = $sanitize($attributes['publicname']);\n            $attributes['realname'] = $sanitize($attributes['realname']);\n            $attributes['nickname'] = $sanitize($attributes['nickname']);\n            $attributes['organisation'] = $sanitize($attributes['organisation']);\n            $attributes['department'] = $sanitize($attributes['department']);\n            $attributes['jobtitle'] = $sanitize($attributes['jobtitle']);\n\n            return $attributes;\n        });\n    }\n\n    private function contributor_form($form_args, $contributor, $action)\n    {\n        $general_fields = [\n            'realname' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Real name', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input required podlove-contributor-field'],\n                ],\n            ],\n            'publicname' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Public name', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('The Public Name will be used for public mentions.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n            'nickname' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Nickname', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n            'gender' => [\n                'field_type' => 'select',\n                'field_options' => [\n                    'label' => __('Gender', 'podlove-podcasting-plugin-for-wordpress'),\n                    'options' => ['female' => 'Female', 'male' => 'Male', 'none' => 'Not attributed'],\n                ],\n            ],\n            'privateemail' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Contact email', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('The provided email will be used for internal purposes only.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-contributor-field podlove-check-input', 'data-podlove-input-type' => 'email'],\n                ],\n            ],\n            'avatar' => [\n                'field_type' => 'upload',\n                'field_options' => [\n                    'label' => __('Avatar', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('Either a Gravatar email adress or a URL.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => [\n                        'class' => 'podlove-contributor-field podlove-check-input',\n                        'data-podlove-input-type' => 'avatar',\n                    ],\n                    'allow_gravatar' => true,\n                ],\n            ],\n            'identifier' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('ID', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('The ID will be used as in internal identifier for e.g. shortcodes.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input required podlove-contributor-field'],\n                ],\n            ],\n            'guid' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('URI', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('An URI acts as a globally unique ID to identify contributors across podcasts on the internet.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n            'visibility' => [\n                'field_type' => 'radio',\n                'field_options' => [\n                    'label' => __('Visibility', 'podlove-podcasting-plugin-for-wordpress'),\n                    'options' => ['1' => __('Yes, the contributor’s information will be visible for the public (e.g. displayed in the Contributor Table).<br />', 'podlove-podcasting-plugin-for-wordpress'),\n                        '0' => __('No, the contributor’s information will be private and not visible for anybody.', 'podlove-podcasting-plugin-for-wordpress'), ],\n                    'default' => '1',\n                ],\n            ],\n        ];\n\n        $general_fields = apply_filters('podlove_contributors_general_fields', $general_fields);\n\n        $affiliation_fields = [\n            'organisation' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Organisation', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n            'department' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Department', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n            'jobtitle' => [\n                'field_type' => 'string',\n                'field_options' => [\n                    'label' => __('Job Title', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'podlove-check-input podlove-contributor-field'],\n                ],\n            ],\n        ];\n\n        $affiliation_fields = apply_filters('podlove_contributors_affiliation_fields', $affiliation_fields);\n\n        $form_sections = [\n            'general' => [\n                'title' => __('General', 'podlove-podcasting-plugin-for-wordpress'),\n                'fields' => $general_fields,\n            ],\n            'affiliation' => [\n                'title' => __('Affiliation', 'podlove-podcasting-plugin-for-wordpress'),\n                'fields' => $affiliation_fields,\n            ],\n        ];\n\n        $form_sections = apply_filters('podlove_contributor_settings_sections', $form_sections);\n\n        if ($_GET['action'] !== 'new') {\n            $contributor = \\Podlove\\Modules\\Contributors\\Model\\Contributor::find_by_id($_REQUEST['contributor']);\n        }\n\n        \\Podlove\\Form\\build_for($contributor, $form_args, function ($form) use ($form_sections) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            foreach ($form_sections as $form_section) {\n                $wrapper->subheader($form_section['title']);\n                foreach ($form_section['fields'] as $field_name => $field) {\n                    call_user_func_array([$wrapper, $field['field_type']], [$field_name, $field['field_options']]);\n                }\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/tab/defaults.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass Defaults extends Tab\n{\n    public $object;\n\n    public function get_slug()\n    {\n        return 'defaults';\n    }\n\n    public function init()\n    {\n        $this->page_type = 'custom';\n        add_action('podlove_expert_settings_page', [$this, 'register_page']);\n    }\n\n    public function register_page()\n    {\n        $this->object = $this->getObject();\n        $this->object->page();\n    }\n\n    public function getObject()\n    {\n        return new \\Podlove\\Modules\\Contributors\\Settings\\ContributorDefaults('podlove_contributor_settings');\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/tab/groups.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass Groups extends Tab\n{\n    private $page;\n\n    public function get_slug()\n    {\n        return 'groups';\n    }\n\n    public function init()\n    {\n        $this->page_type = 'custom';\n        add_action('podlove_expert_settings_page', [$this, 'register_page']);\n    }\n\n    public function register_page()\n    {\n        $this->page = $this->getObject();\n        $this->page->page();\n    }\n\n    public function getObject()\n    {\n        if (!$this->page) {\n            $this->createObject();\n        }\n\n        return $this->page;\n    }\n\n    public function createObject()\n    {\n        $this->page = new \\Podlove\\Modules\\Contributors\\Settings\\GenericEntitySettings(\n            'group',\n            '\\Podlove\\Modules\\Contributors\\Model\\ContributorGroup'\n        );\n\n        $this->page->set_form(function ($form_args, $group, $action) {\n            \\Podlove\\Form\\build_for($group, $form_args, function ($form) {\n                $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n                $wrapper->string('title', [\n                    'label' => __('Group Title', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'required'],\n                ]);\n\n                $wrapper->string('slug', [\n                    'label' => __('Group Slug', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'required'],\n                ]);\n            });\n        });\n\n        $this->page->enable_tabs('groups');\n        $this->page->set_labels([\n            'delete_confirm' => __('You selected to delete the group \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'),\n            'add_new' => __('Add new group', 'podlove-podcasting-plugin-for-wordpress'),\n            'edit' => __('Edit group', 'podlove-podcasting-plugin-for-wordpress'),\n        ]);\n\n        add_action('podlove_settings_group_view', function () {\n            echo sprintf(\n                __('Use groups to divide contributors by type of participation. Create a group for teams working together or for a supporting community. Team members can be displayed separately by using the %sappropriate option%s to select a group.', 'podlove-podcasting-plugin-for-wordpress'),\n                '<a href=\"http://docs.podlove.org/ref/template-tags.html#contributors\" target=\"_blank\">',\n                '</a>'\n            );\n            $table = new \\Podlove\\Modules\\Contributors\\Contributor_Group_List_Table();\n            $table->prepare_items();\n            $table->display();\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/settings/tab/roles.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Settings\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass Roles extends Tab\n{\n    private $page;\n\n    public function get_slug()\n    {\n        return 'roles';\n    }\n\n    public function init()\n    {\n        $this->page_type = 'custom';\n        add_action('podlove_expert_settings_page', [$this, 'register_page']);\n    }\n\n    public function register_page()\n    {\n        $this->page = $this->getObject();\n        $this->page->page();\n    }\n\n    public function getObject()\n    {\n        if (!$this->page) {\n            $this->createObject();\n        }\n\n        return $this->page;\n    }\n\n    public function createObject()\n    {\n        $this->page = new \\Podlove\\Modules\\Contributors\\Settings\\GenericEntitySettings(\n            'role',\n            '\\Podlove\\Modules\\Contributors\\Model\\ContributorRole'\n        );\n\n        $this->page->set_form(function ($form_args, $role, $action) {\n            \\Podlove\\Form\\build_for($role, $form_args, function ($form) {\n                $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n                $wrapper->string('title', [\n                    'label' => __('Role Title', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'required'],\n                ]);\n\n                $wrapper->string('slug', [\n                    'label' => __('Role Slug', 'podlove-podcasting-plugin-for-wordpress'),\n                    'html' => ['class' => 'required'],\n                ]);\n            });\n        });\n\n        $this->page->enable_tabs('roles');\n        $this->page->set_labels([\n            'delete_confirm' => __('You selected to delete the role \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'),\n            'add_new' => __('Add new role', 'podlove-podcasting-plugin-for-wordpress'),\n            'edit' => __('Edit role', 'podlove-podcasting-plugin-for-wordpress'),\n        ]);\n\n        add_action('podlove_settings_role_view', function () {\n            echo __('Use roles to assign a certain type of activity to a single contributor independent of any assigned group. A role might be helpful to mark somebody as being the main presenter of a show or a guest. Use roles sparingly as most of the times, groups might the more valuable way to structure contributors.', 'podlove-podcasting-plugin-for-wordpress');\n            $table = new \\Podlove\\Modules\\Contributors\\Contributor_Role_List_Table();\n            $table->prepare_items();\n            $table->display();\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/shortcodes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\n\n/**\n * Register all contributors shortcodes.\n */\nclass Shortcodes\n{\n    /**\n     * List of contributions to be rendered.\n     */\n    private $contributions = [];\n\n    /**\n     * Shortcode settings.\n     */\n    private $settings = [];\n\n    public function __construct()\n    {\n        // display a table/list of episode contributors\n        add_shortcode('podlove-episode-contributor-list', [$this, 'podlove_contributor_list']);\n        // display a table/list of podcast contributors\n        add_shortcode('podlove-podcast-contributor-list', [$this, 'podlove_podcast_contributor_list']);\n        // display a table/list of all contributors\n        add_shortcode('podlove-global-contributor-list', [$this, 'global_contributor_list']);\n    }\n\n    public static function shortcode_defaults()\n    {\n        $defaults = [\n            'preset' => 'table',\n            'avatars' => 'yes',\n            'role' => 'all',\n            'roles' => 'no',\n            'group' => 'all',\n            'groups' => 'no',\n            'donations' => 'yes',\n            'title' => '',\n            'groupby' => 'none',\n        ];\n\n        return apply_filters('podlove_contributors_shortcode_defaults', $defaults);\n    }\n\n    public function global_contributor_list($attributes)\n    {\n        if (!is_array($attributes)) {\n            $attributes = [];\n        }\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@contributors/podcast-contributor-list.twig', $attributes);\n    }\n\n    /**\n     * Legacy Contributors Shortcode.\n     *\n     * Examples:\n     *\n     *\t[podlove-contributors]\n     *\n     * @param mixed $attributes\n     *\n     * @return string\n     */\n    public function podlove_contributors($attributes)\n    {\n        $this->podlove_contributor_list($attributes);\n    }\n\n    /**\n     * Parameters:.\n     *\n     *\tpreset      - One of 'table', 'list', 'comma separated'. Default: 'table'\n     *\ttitle       - Optional table header title. Default: none\n     *\tavatars     - One of 'yes', 'no'. Display avatars. Default: 'yes'\n     *\trole        - Filter lists by role. Default: 'all'\n     *\troles       - One of 'yes', 'no'. Display role. Default: 'no'\n     *\tgroup       - Filter lists by group. Default: 'all'\n     *\tgroups      - One of 'yes', 'no'. Display group. Default: 'no'\n     *\tgroupby     - Set to 'group' to group contributors by their contributor group. Default: 'none'\n     *\tdonations   - One of 'yes', 'no'. Display donation column. Default: 'no'\n     *\t              Links contributor name to the service if available. Default: 'none'\n     *\n     * Examples:\n     *\n     *\t[podlove-episode-contributor-list]\n     *\n     * @param mixed $attributes\n     *\n     * @return string\n     */\n    public function podlove_contributor_list($attributes)\n    {\n        if (!is_array($attributes)) {\n            $attributes = [];\n        }\n\n        $this->settings = array_merge(self::shortcode_defaults(), $attributes);\n\n        switch ($this->settings['preset']) {\n            case 'comma separated':\n                $file = '@contributors/contributor-comma-separated.twig';\n\n                break;\n            case 'list':\n                $file = '@contributors/contributor-list.twig';\n\n                break;\n            case 'table':\n                $file = '@contributors/contributor-table.twig';\n\n                break;\n\n            default:\n                $file = '@contributors/contributor-table.twig';\n\n                break;\n        }\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html($file, $this->settings);\n    }\n\n    public function podlove_podcast_contributor_list($attributes)\n    {\n        if (!is_array($attributes)) {\n            $attributes = [];\n        }\n\n        $this->settings = array_merge(self::shortcode_defaults(), $attributes);\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@contributors/podcast-contributor-table.twig', $this->settings);\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/template/avatar.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Template;\n\nuse Podlove\\Template\\Image;\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Contributor Avatar Template Wrapper.\n *\n * Requires the \"Contributor\" module.\n *\n * @deprecated since 2.2.0\n *\n * @templatetag avatar\n */\nclass Avatar extends Wrapper\n{\n    private $contributor;\n\n    public function __construct($contributor)\n    {\n        $this->contributor = $contributor;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Avatar image URL.\n     *\n     * Dimensions default to 50x50px.\n     * Change it via parameter: `avatar.url(32)`\n     *\n     * @accessor\n     *\n     * @param mixed $size\n     */\n    public function url($size = 50)\n    {\n        return $this->contributor->avatar()->setWidth($size)->url();\n    }\n\n    /**\n     * Avatar image tag.\n     *\n     * Dimensions default to 50x50px.\n     * Change it via parameter: `avatar.html({width: 100})`\n     *\n     * @accessor\n     *\n     * @see image\n     *\n     * @param mixed $args\n     */\n    public function html($args = [])\n    {\n        if (!isset($args['width'])) {\n            $args['width'] = 50;\n        }\n\n        $image = new Image($this->contributor->avatar());\n\n        return $image->html($args);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->contributor];\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/template/contributor.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Template;\n\nuse Podlove\\Modules\\Contributors\\Model;\nuse Podlove\\Template\\Episode;\nuse Podlove\\Template\\Image;\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Contributor Template Wrapper.\n *\n * Requires the \"Contributor\" module.\n *\n * @templatetag contributor\n */\nclass Contributor extends Wrapper\n{\n    private $contributor;\n    private $contribution;\n\n    public function __construct(Model\\Contributor $contributor, $contribution = null)\n    {\n        $this->contributor = $contributor;\n        $this->contribution = $contribution;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Is the contributor public?\n     *\n     * @accessor\n     */\n    public function visible()\n    {\n        return (bool) $this->contributor->visibility;\n    }\n\n    /**\n     * Name.\n     *\n     * Public name of the contributor. If no public name is set,\n     * it defaults to the real name.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->contributor->getName();\n    }\n\n    /**\n     * Real name.\n     *\n     * You should use `contributor.name` as display name.\n     *\n     * @accessor\n     */\n    public function realname()\n    {\n        return $this->contributor->realname;\n    }\n\n    /**\n     * Nickname.\n     *\n     * @accessor\n     */\n    public function nickname()\n    {\n        return $this->contributor->nickname;\n    }\n\n    /**\n     * ID.\n     *\n     * @accessor\n     */\n    public function id()\n    {\n        return $this->contributor->identifier;\n    }\n\n    /**\n     * URI.\n     *\n     * @accessor\n     */\n    public function uri()\n    {\n        return $this->contributor->guid;\n    }\n\n    /**\n     * Public name.\n     *\n     * You should use `contributor.name` as display name.\n     *\n     * @accessor\n     */\n    public function publicname()\n    {\n        return $this->contributor->publicname;\n    }\n\n    /**\n     * Gender.\n     *\n     * Either 'female', 'male', 'none' or null (not configured).\n     *\n     * @accessor\n     */\n    public function gender()\n    {\n        return $this->contributor->gender;\n    }\n\n    /**\n     * Contribution role.\n     *\n     * A role is only available for `episode.contributors` and `podcast.contributors`,\n     * not if you access the global `contributors` directly.\n     *\n     * @accessor\n     */\n    public function role()\n    {\n        if ($role = $this->contribution->getRole()) {\n            return $role->title;\n        }\n\n        return '';\n    }\n\n    /**\n     * Contribution group.\n     *\n     * A group is only available for `episode.contributors` and `podcast.contributors`,\n     * not if you access the global `contributors` directly.\n     *\n     * @accessor\n     */\n    public function group()\n    {\n        if ($group = $this->contribution->getGroup()) {\n            return $group->title;\n        }\n\n        return '';\n    }\n\n    /**\n     * Contribution comment.\n     *\n     * @accessor\n     */\n    public function comment()\n    {\n        return ($this->contribution) ? $this->contribution->comment : '';\n    }\n\n    /**\n     * Avatar image.\n     *\n     * Dimensions default to 50x50px.\n     * Change it via parameter: `contributor.avatar(32)`\n     *\n     * To render an HTML image tag:\n     * `{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar} only %}`\n     * or\n     * `{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar, 'size': 150} only %}`\n     *\n     * @deprecated use contributor.image instead\n     *\n     * @accessor\n     *\n     * @param mixed $size\n     */\n    public function avatar($size = 50)\n    {\n        return new Avatar($this->contributor, $size);\n    }\n\n    /**\n     * Avatar Image.\n     *\n     * @see image\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return new Image($this->contributor->avatar());\n    }\n\n    /**\n     * Email address for internal use.\n     *\n     * @accessor\n     */\n    public function contactemail()\n    {\n        return $this->contributor->contactemail;\n    }\n\n    /**\n     * Affiliation: organisation.\n     *\n     * @accessor\n     */\n    public function organisation()\n    {\n        return $this->contributor->organisation;\n    }\n\n    /**\n     * Affiliation: department.\n     *\n     * @accessor\n     */\n    public function department()\n    {\n        return $this->contributor->department;\n    }\n\n    /**\n     * Affiliation: jobtitle.\n     *\n     * @accessor\n     */\n    public function jobtitle()\n    {\n        return $this->contributor->jobtitle;\n    }\n\n    /**\n     * Episodes with this contributor.\n     *\n     * Filter and order episodes with parameters:\n     *\n     * - group: Filter by contribution group. Default: ''.\n     * - role: Filter by contribution role. Default: ''.\n     * - post_status: Publication status of the post. Defaults to 'publish'\n     * - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'.\n     *   - 'publicationDate' - Order by publication date.\n     *   - 'recordingDate' - Order by recording date.\n     *   - 'title' - Order by title.\n     *   - 'slug' - Order by episode slug.\n     *\t - 'limit' - Limit the number of returned episodes.\n     *\n     * @see  episode\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        return array_map(function ($e) {\n            return new Episode($e);\n        }, $this->contributor->episodes($args));\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->contributor, $this->contribution];\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/template/contributor_group.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * ContributorGroup Template Wrapper.\n *\n * Requires the \"Contributor\" module.\n *\n * @templatetag contributor_group\n */\nclass ContributorGroup extends Wrapper\n{\n    private $group;\n    private $contributions;\n\n    public function __construct($group, $contributions = null)\n    {\n        $this->group = $group;\n        $this->contributions = $contributions;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->group->title;\n    }\n\n    /**\n     * URL slug.\n     *\n     * @accessor\n     */\n    public function slug()\n    {\n        return (bool) $this->group->slug;\n    }\n\n    /**\n     * Contributors in this group.\n     *\n     * Depending on context *all* contributors or just the contributors relevant to the current context.\n     *\n     * @see  contributor\n     *\n     * @accessor\n     */\n    public function contributors()\n    {\n        return array_map(function ($contribution) {\n            return new \\Podlove\\Modules\\Contributors\\Template\\Contributor($contribution->getContributor(), $contribution);\n        }, $this->contributions);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->group, $this->contributions];\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Contributors;\n\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\nuse Podlove\\Modules\\Contributors\\Model\\ShowContribution;\n\nclass TemplateExtensions\n{\n    /**\n     * List of episode contributors.\n     *\n     * **Examples**\n     *\n     * Iterating over a list of contributors\n     *\n     * ```jinja\n     * {% for contributor in episode.contributors %}\n     * \t{{ contributor.name }}\n     * \t{% if not loop.last %}, {% endif %}\n     * {% endfor %}\n     * ```\n     *\n     * Iterating over a grouped list of contributors\n     *\n     * ```jinja\n     * {% for contributorGroup in episode.contributors({groupby: \"group\"}) %}\n     * \t<strong>{{ contributorGroup.group.title }}:</strong>\n     * \t{% for contributor in contributorGroup.contributors %}\n     * \t\t{{ contributor.name }}\n     * \t\t{% if not loop.last %}, {% endif %}\n     * \t{% endfor %}\n     * {% endfor %}\n     * ```\n     *\n     * **Parameters**\n     *\n     * - **id:**      Fetch one contributor by its id.\n     *                Example: `episode.contributors({id: 'james'}).name`\n     * - **group:**   group slug. If none is given, show all contributors.\n     * - **role:**    role slug. If none is given, show all contributors.\n     * - **groupby:** group or role slug. Group by \"group\" or \"role\".\n     * \t         If used, the returned data is has another layer for the groups.\n     * \t         See examples for more details.\n     * - **order:**   Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'ASC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - **orderby:** Sort contributors by parameter. Defaults to 'position'.\n     *   - 'position' - Order by the contributors position in the episode.\n     *   - 'comment' - Order by the contributors comment in the episode.\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.contributors\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $episode\n     * @param mixed $post\n     * @param mixed $args\n     */\n    public static function accessorEpisodeContributors($return, $method_name, $episode, $post, $args = [])\n    {\n        return $episode->with_blog_scope(function () use ($episode, $args) {\n            $defaults = [\n                'order' => 'ASC',\n                'orderby' => 'position',\n            ];\n            $args = wp_parse_args($args, $defaults);\n\n            $contributions = EpisodeContribution::find_all_by_episode_id($episode->id);\n            $contributions = \\Podlove\\Modules\\Contributors\\Contributors::orderContributions($contributions, $args);\n\n            return \\Podlove\\Modules\\Contributors\\Contributors::filterContributions($contributions, $args);\n        });\n    }\n\n    /**\n     * List of podcast contributors.\n     *\n     * **Examples**\n     *\n     * Iterating over a list of contributors\n     *\n     * ```jinja\n     * {% for contributor in podcast.contributors({scope: \"podcast\"}) %}\n     * \t{{ contributor.name }}\n     * \t{% if not loop.last %}, {% endif %}\n     * {% endfor %}\n     * ```\n     *\n     * Iterating over a grouped list of contributors\n     *\n     * ```jinja\n     * {% for contributorGroup in podcast.contributors({scope: \"podcast\", groupby: \"group\"}) %}\n     * \t<strong>{{ contributorGroup.group.title }}:</strong>\n     * \t{% for contributor in contributorGroup.contributors %}\n     * \t\t{{ contributor.name }}\n     * \t\t{% if not loop.last %}, {% endif %}\n     * \t{% endfor %}\n     * {% endfor %}\n     * ```\n     *\n     * **Parameters**\n     *\n     * - **id:**      Fetch one contributor by its id. DEPRECATED: Use `podcast.contributor(id)` instead.\n     * - **scope:**   Either \"global\", \"global-active\" or \"podcast\".\n     *                - \"global\" returns all contributors.\n     *                - \"global-active\" returns all contributors with\n     *                   at least one contribution in a published episode.\n     * \t              - \"podcast\" returns the contributors configured in podcast settings.\n     * \t              Default: \"global-active\".\n     * - **group:**   filter by group slug. Defaults to \"all\", which does not filter.\n     * - **role:**    filter by role slug. Defaults to \"all\", which does not filter.\n     * - **groupby:** group or role slug. Group by \"group\" or \"role\".\n     * \t              If used, the returned data is has another layer for the groups.\n     * \t              See examples for more details.\n     * - **order:**   Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - **orderby:** Sort contributors by parameter. Defaults to 'name'.\n     *   - 'name' - Order by public name.\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.contributors\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $podcast\n     * @param mixed $args\n     */\n    public static function accessorPodcastContributors($return, $method_name, $podcast, $args = [])\n    {\n        return $podcast->with_blog_scope(function () use ($args) {\n            $args = shortcode_atts([\n                'id' => null,\n                'scope' => 'global-active',\n                'group' => 'all',\n                'role' => 'all',\n                'groupby' => null,\n                'order' => 'ASC',\n                'orderby' => 'name',\n            ], $args);\n\n            if ($args['id']) {\n                $contributor = Contributor::find_one_by_identifier($args['id']);\n\n                if ($contributor) {\n                    return new Template\\Contributor($contributor);\n                }\n\n                return null;\n            }\n\n            $scope = in_array($args['scope'], ['global', 'global-active', 'podcast']) ? $args['scope'] : 'global-active';\n\n            $contributors = [];\n            if (in_array($scope, ['global', 'global-active'])) {\n                // fetch by group and/or role. defaults to *all* contributors\n                // if no role or group are given\n                $group = $args['group'] !== 'all' ? $args['group'] : null;\n                $role = $args['role'] !== 'all' ? $args['role'] : null;\n                $contributors = Contributor::byGroupAndRole($group, $role);\n\n                if ($scope == 'global-active') {\n                    $contributors = array_filter($contributors, function ($contributor) {\n                        return $contributor->getPublishedContributionCount() > 0;\n                    });\n                }\n\n                $contributors = array_map(function ($contributor) {\n                    return new Template\\Contributor($contributor);\n                }, $contributors);\n            } else {\n                $contributions = ShowContribution::all();\n                $contributors = \\Podlove\\Modules\\Contributors\\Contributors::filterContributions($contributions, $args);\n            }\n\n            $sort_by_name = function ($a, $b) {\n                return strcmp(strtolower($a->name()), strtolower($b->name()));\n            };\n\n            // sort\n            if ($args['groupby'] == 'group') {\n                foreach ($contributors as $group_id => $group) {\n                    usort($contributors[$group_id]['contributors'], $sort_by_name);\n\n                    if (strtoupper($args['order']) == 'DESC') {\n                        $contributors[$group_id]['contributors'] = array_reverse($contributors[$group_id]['contributors']);\n                    }\n                }\n            } else {\n                usort($contributors, $sort_by_name);\n\n                if (strtoupper($args['order']) == 'DESC') {\n                    $contributors = array_reverse($contributors);\n                }\n            }\n\n            return $contributors;\n        });\n    }\n\n    /**\n     * Get one contributor by id.\n     *\n     * **Examples**\n     *\n     * Iterating over a list of contributors\n     *\n     * ```jinja\n     * {{ podcast.contributor('james').name }}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.contributor\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $podcast\n     * @param mixed $id\n     */\n    public static function accessorPodcastContributor($return, $method_name, $podcast, $id)\n    {\n        $contributor = Contributor::find_one_by_identifier($id);\n\n        if ($contributor) {\n            return new Template\\Contributor($contributor);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "lib/modules/contributors/templates/_contributor-table-flattr.twig",
    "content": "<script type=\"text/javascript\">\n(function() {\n\tvar s = document.createElement('script'), t = document.getElementsByTagName('script')[0];\n\ts.type = 'text/javascript';\n\ts.async = true;\n\ts.src = 'https://api.flattr.com/js/0.6/load.js?mode=auto';\n\tt.parentNode.insertBefore(s, t);\n})();\n</script>"
  },
  {
    "path": "lib/modules/contributors/templates/_contributor-table-row.twig",
    "content": "<tr>\n\t{% if option.avatars == \"yes\" %}\n\t\t<td class=\"avatar_cell\">\n\t\t\t{{ contributor.image.html({width: size|default(50), height: size|default(50), class: \"avatar avatar-\" ~ size|default(50) ~  \" photo\", alt: \"avatar\" }) }}\n\t\t</td>\n\t{% endif %}\n\t<td class=\"title_cell\">\n\t\t{{ contributor.name }}\n\t\t{% if contributor.comment %}\n\t\t\t<br><em>{{ contributor.comment }}</em>\n\t\t{% endif %}\n\t</td>\n\t{% if option.groups == \"yes\" %}\n\t\t<td class=\"group_cell\">\n\t\t\t{{ contributor.group }}\n\t\t</td>\n\t{% endif %}\n\t{% if option.roles == \"yes\" %}\n\t\t<td class=\"role_cell\">\n\t\t\t{{ contributor.role }}\n\t\t</td>\n\t{% endif %}\n\t<td class=\"social_cell\">\n\t\t{% for service in contributor.services({category: \"social\"}) %}\n\t\t\t<a target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t{{\n\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\tclass: \"podlove-contributor-button\",\n\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t}) \n\t\t\t\t}}\n\t\t\t</a>\n\t\t{% endfor %}\n\t</td>\n\t{% if option.donations == \"yes\" %}\n\t\t<td class=\"donation_cell\">\n\t\t{% for service in contributor.services({category: \"donation\"}) %}\n\t\t\t<a target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t{{\n\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\tclass: \"podlove-contributor-button\",\n\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t}) \n\t\t\t\t}}\n\t\t\t</a>\n\t\t{% endfor %}\n\t\t</td>\n\t{% endif %}\n\t{% if option.flattr == \"yes\" %}\n\t\t<td class=\"flattr_cell\">\n\t\t\t{% if contributor.flattr %}\n\t\t\t\t{% set cTitle = contributor.name ~ (episode ? \" @ \" ~ episode.title : \"\" ) %}\n\t\t\t\t<a target=\"_blank\"\n\t\t\t\t\tclass=\"FlattrButton\"\n\t\t\t\t\tstyle=\"display:none;\"\n\t\t\t\t\ttitle=\"Flattr {{ cTitle }}\"\n\t\t\t\t\trel=\"flattr;uid:{{ contributor.flattr }};button:compact;popout:0\"\n\t\t\t\t\thref=\"{{ contributor.flattr_url }}\">\n\t\t\t\t\t\t{{ cTitle }}\n\t\t\t\t</a>\n\t\t\t{% endif %}\n\t\t</td>\n\t{% endif %}\n</tr>"
  },
  {
    "path": "lib/modules/contributors/templates/avatar.twig",
    "content": "{#\nDisplay a contributor avatar.\n\nUsage examples:\n\t{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar} only %}\n\t{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar, 'size': 150} only %} \n#}\n{% set size = size|default(50) %}\n<img alt=\"avatar\" src=\"{{ avatar.url(size) }}\" class=\"avatar avatar-{{ size }} photo\" height=\"{{ size }}\" width=\"{{ size }}\" />"
  },
  {
    "path": "lib/modules/contributors/templates/contributor-comma-separated.twig",
    "content": "<span class=\"podlove-contributors\">\n\t{% for contributor in episode.contributors({group: group, role: role}) %}\n\t\t{% if contributor.visible %}\n\t\t\t<span>\n\t\t\t\t{% if option.avatars == \"yes\" %}\n\t\t\t\t\t{{ contributor.image.html({width: 18, height: 18, class: \"avatar avatar-\" ~ size ~  \" photo\", alt: \"avatar\" }) }}\n\t\t\t\t{% endif %}\n\t\t\t\t<span class=\"name\">{{ contributor.name }}</span></span>{% if not loop.last %}, {% endif %}\n\t\t{% endif %}\n\t{% endfor %}\n</span>"
  },
  {
    "path": "lib/modules/contributors/templates/contributor-list.twig",
    "content": "<ul class=\"podlove-contributors\">\n{% for contributor in episode.contributors({group: group, role: role}) %}\n\t{% if contributor.visible %}\n\t\t<li>\n\t\t\t{% if option.avatars == \"yes\" %}\n\t\t\t\t<span class=\"avatar\">\n\t\t\t\t\t{{ contributor.image.html({width: size|default(50), height: size|default(50), class: \"avatar avatar-\" ~ size|default(50) ~  \" photo\", alt: \"avatar\" }) }}\n\t\t\t\t</span>\n\t\t\t{% endif %}\n\t\t\t<span class=\"name\">{{ contributor.name }}</span>\n\t\t</li>\n\t{% endif %}\n{% endfor %}\n</ul>"
  },
  {
    "path": "lib/modules/contributors/templates/contributor-table.twig",
    "content": "{% set colspan = 2 %}\n{% if avatars == \"yes\"   %}\n\t{% set colspan = colspan + 1 %}\n{% endif %}\n{% if groups == \"yes\"    %}\n\t{% set colspan = colspan + 1 %}\n{% endif %}\n{% if roles == \"yes\"     %}\n\t{% set colspan = colspan + 1 %}\n{% endif %}\n{% if donations == \"yes\" %}\n\t{% set colspan = colspan + 1 %}\n{% endif %}\n\n{% if is_feed() %}\n\n\t<ul>\n\t\t{% for contributor in episode.contributors({group: option.group, role: option.role}) %}\n\t\t\t{% if contributor.visible %}\n\t\t\t\t<li>\n\t\t\t\t\t<strong>{{ contributor.name }}</strong>\n\t\t\t\t\t{% if contributor.comment %}({{ contributor.comment }})\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t<ul>\n\t\t\t\t\t\t{% for service in contributor.services({category: \"social\"}) %}\n\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t<a href=\"{{ service.profileUrl }}\">{{ service.title }}</a>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t{% if option.donations == \"yes\" %}\n\t\t\t\t\t\t\t{% for service in contributor.services({category: \"donation\"}) %}\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<a href=\"{{ service.profileUrl }}\">{{ service.title }}</a>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t</ul>\n\t\t\t\t</li>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t</ul>\n\n{% else %}\n\n\t<div class=\"podlove-contributors-cards\">\n\t\t{% for contributor in episode.contributors({group: option.group, role: option.role}) %}\n\t\t\t{% if contributor.visible %}\n\n\t\t\t\t<div class=\"podlove-contributors-card\">\n\t\t\t\t\t<div class=\"podlove-contributors-card-inner\">\n\t\t\t\t\t\t<div class=\"podlove-contributors-card-avatar\">\n\t\t\t\t\t\t\t{{ contributor.image.html({width: size|default(50), height: size|default(50), class: \"\", alt: \"avatar\" }) }}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"podlove-contributors-card-person\">\n\t\t\t\t\t\t\t<div style=\"align-self: center\">\n\t\t\t\t\t\t\t\t<div style=\"font-weight: 400;\">{{ contributor.name }}</div>\n\t\t\t\t\t\t\t\t{% if contributor.comment %}\n\t\t\t\t\t\t\t\t\t<div class=\"podlove-contributors-card-person-details\">\n\t\t\t\t\t\t\t\t\t\t<span>{{ contributor.comment }}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"podlove-contributors-card-services\">\n\t\t\t\t\t\t\t\t{% for service in contributor.services({category: \"social\"}) %}\n\t\t\t\t\t\t\t\t\t<a class=\"podlove-contributors-card-services-service\" target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t\t\t\t\t\t\t{{\n\t\t\t\t\t\t\t\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\t\t\t\t\t\t\t\tclass: \"\",\n\t\t\t\t\t\t\t\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t}) \n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t\t\t{% if option.donations == \"yes\" %}\n\t\t\t\t\t\t\t\t\t{% for service in contributor.services({category: \"donation\"}) %}\n\t\t\t\t\t\t\t\t\t\t<a class=\"podlove-contributors-card-services-service\" target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t\t\t\t\t\t\t\t{{\n\t\t\t\t\t\t\t\t\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass: \"\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t}) \n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t</div>\n\n\t<style>\n\t\t.podlove-contributors-cards {\n\t\t\tmargin-bottom: 1rem;\n\t\t\tbackground: white;\n\t\t\toverflow: hidden;\n\t\t\tborder-radius: 0.375rem;\n\t\t\tbox-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);\n\t\t}\n\t\t.podlove-contributors-card {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tpadding: 1rem 1.5rem\n\t\t}\n\t\t.podlove-contributors-card-inner {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tflex: 1 1 0;\n\t\t\tmin-width: 0;\n\t\t}\n\t\t.podlove-contributors-card-avatar {\n\t\t\twidth: 50px;\n\t\t\theight: 50px;\n\t\t\tborder-radius: 0.25rem;\n\t\t\toverflow: hidden;\n\t\t\tflex-shrink: 0;\n\t\t}\n\t\t.podlove-contributors-card-person {\n\t\t\tflex: 1 1 0;\n\t\t\tmin-width: 0;\n\t\t\tpadding: 0 1rem;\n\t\t\tdisplay: grid;\n\t\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\t\tgap: 1rem;\n\t\t}\n\t\t.podlove-contributors-card-person-details {\n\t\t\tcolor: #999\n\t\t}\n\t\t.podlove-contributors-card-services {\n\t\t\tflex-wrap: wrap;\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\talign-content: center;\n\t\t\tjustify-content: flex-end;\n\t\t}\n\t\t.podlove-contributors-card-services-service {\n\t\t\tdisplay: inline-block;\n\t\t\tflex-shrink: 0;\n\t\t\tpadding-left: 0.5rem;\n\t\t\ttext-decoration: none;\n\t\t\tbox-shadow: none;\n\t\t}\n\t</style>\n\n{% endif %}\n"
  },
  {
    "path": "lib/modules/contributors/templates/podcast-contributor-list.twig",
    "content": "<table class=\"podlove-global-contributors\">\n\t{% if option.title %}\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th colspan=\"2\">{{ option.title }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t{% endif %}\n\t<tbody>\n\t\t{% for contributor in podcast.contributors %}\n\t\t\t{% if contributor.visible %}\n\t\t\t\t<tr>\n\t\t\t\t\t<td rowspan=\"2\" class=\"avatar-cell\" width=\"60\">\n\t\t\t\t\t\t{{ contributor.image.html({width: 60, height: 60, class: \"avatar avatar-\" ~ size ~  \" photo\", alt: \"avatar\" }) }}\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"social-cell\">\n\t\t\t\t\t\t<strong class=\"contributor-name\">{{ contributor.name }}</strong>\n\t\t\t\t\t\t<div class=\"social-icons\">\n\t\t\t\t\t\t\t{% for service in contributor.services %}\n\t\t\t\t\t\t\t\t<a target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t\t\t\t\t\t{{\n\t\t\t\t\t\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\t\t\t\t\t\twidth: 32,\n\t\t\t\t\t\t\t\t\t\t\tclass: \"podlove-contributor-button\",\n\t\t\t\t\t\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr class=\"episode-row\">\n\t\t\t\t\t<td class=\"episodes-cell\">\n\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t{% for episode in contributor.episodes %}\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<a href=\"{{ episode.url }}\">{{ episode.title }}</a>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t</tbody>\n</table>\n\n<script type=\"text/javascript\">\n\t(function ($) {\n$(document).ready(function () {\n$(\".podlove-global-contributors .episodes-cell\").each(function () {\nvar items = $(\"li\", this);\n\n\nif (items.length > 5) {\n$(\"li:gt(4)\", this).hide();\n$('<span class=\"show-all-episodes\"><a href=\"#\">… show all episodes</a><span>').insertAfter($(\"ul\", this));\n}\n});\n\n$(\".podlove-global-contributors\").on(\"click\", \".show-all-episodes a\", function (e) {\ne.preventDefault();\n\n$(this).closest(\".episodes-cell\").find(\"li\").show().end().find(\".show-all-episodes\").hide();\n});\n});\n}(jQuery));\n</script>\n\n<style type=\"text/css\">\n\t.podlove-global-contributors td {\n\t\tvertical-align: top;\n\t\tline-height: 1.3em;\n\t}\n\n\t.podlove-global-contributors .avatar-cell {\n\t\tmax-width: 100px;\n\t\ttext-align: center;\n\t}\n\n\t.podlove-global-contributors td {\n\t\tborder-top-width: 0;\n\t}\n\n\t.podlove-global-contributors .episode-row {\n\t\t;\n\t\t/*margin-bottom: 10px;*/\n\t}\n\n\t.podlove-global-contributors td ul {\n\t\tmargin: 0;\n\t}\n\n\t.podlove-global-contributors .social-cell li {\n\t\tmargin: 0;\n\t}\n\n\t.podlove-global-contributors .social-cell .social-icons a {\n\t\ttext-decoration: none;\n\t}\n\n\t.podlove-global-contributors .episodes-cell {\n\t\tpadding-top: 0;\n\t}\n\n\t.podlove-global-contributors .episodes-cell ul {\n\t\tmargin-left: 0;\n\t\tpadding-left: 0;\n\t}\n\n\t.podlove-global-contributors .episodes-cell li {\n\t\tdisplay: inline-block;\n\t\tmargin: 0;\n\t}\n\n\t.podlove-global-contributors .episodes-cell li a {\n\t\tbackground: #eee;\n\t\tpadding: 2px 10px;\n\t\tline-height: 170%;\n\t\tborder-radius: 10px;\n\t\ttext-decoration: none;\n\t}\n</style>\n"
  },
  {
    "path": "lib/modules/contributors/templates/podcast-contributor-table.twig",
    "content": "<div class=\"podlove-contributors-cards\">\n\t{% for contributor in podcast.contributors({group: option.group, role: option.role}) %}\n\t\t{% if contributor.visible %}\n\n\t\t\t<div class=\"podlove-contributors-card\">\n\t\t\t\t<div class=\"podlove-contributors-card-inner\">\n\t\t\t\t    {% if option.avatars == \"yes\" %}\n\t\t\t\t\t\t<div class=\"podlove-contributors-card-avatar\">\n\t\t\t\t\t\t\t{{ contributor.image.html({width: size|default(50), height: size|default(50), class: \"\", alt: \"avatar\" }) }}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t<div class=\"podlove-contributors-card-person\">\n\t\t\t\t\t\t<div style=\"align-self: center\">\n\t\t\t\t\t\t\t<div style=\"font-weight: 400;\">{{ contributor.name }}</div>\n\t\t\t\t\t\t\t{% if contributor.comment %}\n\t\t\t\t\t\t\t\t<div class=\"podlove-contributors-card-person-details\">\n\t\t\t\t\t\t\t\t\t<span>{{ contributor.comment }}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"podlove-contributors-card-services\">\n\t\t\t\t\t\t\t{% for service in contributor.services({category: \"social\"}) %}\n\t\t\t\t\t\t\t\t<a class=\"podlove-contributors-card-services-service\" target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t\t\t\t\t\t{{\n\t\t\t\t\t\t\t\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\t\t\t\t\t\t\t\tclass: \"\",\n\t\t\t\t\t\t\t\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t}) \n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t\t{% if option.donations == \"yes\" %}\n\t\t\t\t\t\t\t\t{% for service in contributor.services({category: \"donation\"}) %}\n\t\t\t\t\t\t\t\t\t<a class=\"podlove-contributors-card-services-service\" target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n\t\t\t\t\t\t\t\t\t\t{{\n\t\t\t\t\t\t\t\t\t\t\t\t\tservice.image.html({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 20, \n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass: \"\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t}) \n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t{% endfor %}\n\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t{% endif %}\n\t{% endfor %}\n</div>\n\n<style>\n\t.podlove-contributors-cards {\n\t\tmargin-bottom: 1rem;\n\t\tbackground: white;\n\t\toverflow: hidden;\n\t\tborder-radius: 0.375rem;\n\t\tbox-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);\n\t}\n\t.podlove-contributors-card {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tpadding: 1rem 1.5rem\n\t}\n\t.podlove-contributors-card-inner {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tflex: 1 1 0;\n\t\tmin-width: 0;\n\t}\n\t.podlove-contributors-card-avatar {\n\t\twidth: 50px;\n\t\theight: 50px;\n\t\tborder-radius: 0.25rem;\n\t\toverflow: hidden;\n\t\tflex-shrink: 0;\n\t}\n\t.podlove-contributors-card-person {\n\t\tflex: 1 1 0;\n\t\tmin-width: 0;\n\t\tpadding: 0 1rem;\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\t\tgap: 1rem;\n\t}\n\t.podlove-contributors-card-person-details {\n\t\tcolor: #999\n\t}\n\t.podlove-contributors-card-services {\n\t\tflex-wrap: wrap;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\talign-content: center;\n\t\tjustify-content: flex-end;\n\t}\n\t.podlove-contributors-card-services-service {\n\t\tdisplay: inline-block;\n\t\tflex-shrink: 0;\n\t\tpadding-left: 0.5rem;\n\t\ttext-decoration: none;\n\t\tbox-shadow: none;\n\t}\n</style>\n"
  },
  {
    "path": "lib/modules/contributors/views/form_table.php",
    "content": "<?php\nuse Podlove\\Modules\\Contributors\\Contributors;\n\n?>\n<div id=\"contributors-form\">\n    <table class=\"podlove_alternating\" border=\"0\" cellspacing=\"0\">\n        <thead>\n            <tr>\n                <th class=\"podlove-avatar-column\" colspand=\"2\"><?php echo __('Contributor', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n                <th></th>\n                <?php echo $has_groups ? '<th>'.__('Group', 'podlove-podcasting-plugin-for-wordpress').'</th>' : ''; ?>\n                <?php echo $has_roles ? '<th>'.__('Role', 'podlove-podcasting-plugin-for-wordpress').'</th>' : ''; ?>\n                <?php echo $can_be_commented ? '<th>'.__('Pub&shy;lic Com&shy;ment', 'podlove-podcasting-plugin-for-wordpress').'</th>' : ''; ?>\n                <th style=\"width: 60px\"><?php echo __('Re&shy;move', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n                <th style=\"width: 30px\"></th>\n            </tr>\n        </thead>\n        <tbody id=\"contributors_table_body\" style=\"min-height: 50px;\">\n            <tr class=\"contributors_table_body_placeholder\" style=\"display: none;\">\n                <td><em><?php echo __('No contributors were added yet.', 'podlove-podcasting-plugin-for-wordpress'); ?></em></td>\n            </tr>\n        </tbody>\n    </table>\n\n    <div id=\"add_new_contributor_wrapper\">\n        <input class=\"button\" id=\"add_new_contributor_button\" value=\"+\" type=\"button\" />\n    </div>\n\n    <script type=\"text/template\" id=\"contributor-row-template\">\n    <tr class=\"media_file_row podlove-contributor-table\" data-contributor-id=\"{{contributor-id}}\" data-row-number=\"{{id}}\">\n        <td class=\"podlove-avatar-column\"></td>\n        <td class=\"podlove-contributor-column\">\n            <div style=\"min-width: 205px\">\n            <select name=\"<?php echo $form_base_name; ?>[{{id}}][{{contributor-id}}][id]\" class=\"chosen-image podlove-contributor-dropdown\">\n                <option value=\"\"><?php echo __('Choose Contributor', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n                <option value=\"create\"><?php echo __('Add New Contributor', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n                <?php foreach ($contributors as $contributor) { ?>\n                    <option value=\"<?php echo $contributor->id; ?>\" data-img-src=\"<?php echo $contributor->avatar()->setWidth(10)->url(); ?>\" data-contributordefaultrole=\"<?php echo $contributor->role; ?>\"><?php echo $contributor->getName(); ?></option>\n                <?php } ?>\n            </select>\n            <a class=\"clickable podlove-icon-edit podlove-contributor-edit\"   href=\"<?php echo Contributors::get_edit_contributor_url('{{contributor-id}}'); ?>\"></a>\n            <a class=\"clickable podlove-icon-plus podlove-contributor-create\" href=\"<?php echo Contributors::get_create_contributor_url(); ?>\"></a>\n            </div>\n        </td>\n        <?php if ($has_groups) { ?>\n        <td style=\"min-width: 90px\">\n            <select name=\"<?php echo $form_base_name; ?>[{{id}}][{{contributor-id}}][group]\" class=\"chosen podlove-group\">\n                <option value=\"\">&nbsp;</option>\n                <?php foreach ($contributors_groups as $group_slug => $group_title) { ?>\n                    <option value=\"<?php echo $group_slug; ?>\"><?php echo $group_title; ?></option>\n                <?php } ?>\n            </select>\n        </td>\n        <?php } ?>\n        <?php if ($has_roles) { ?>\n        <td style=\"min-width: 90px\">\n            <select name=\"<?php echo $form_base_name; ?>[{{id}}][{{contributor-id}}][role]\" class=\"chosen podlove-role\">\n                <option value=\"\">&nbsp;</option>\n                <?php foreach ($contributors_roles as $role_slug => $role_title) { ?>\n                    <option value=\"<?php echo $role_slug; ?>\"><?php echo $role_title; ?></option>\n                <?php } ?>\n            </select>\n        </td>\n        <?php } ?>\n        <?php if ($can_be_commented) { ?>\n        <td>\n            <input type=\"text\" name=\"<?php echo $form_base_name; ?>[{{id}}][{{contributor-id}}][comment]\" class=\"podlove-comment\" />\n        </td>\n        <?php } ?>\n        <td>\n            <span class=\"contributor_remove\">\n                <i class=\"clickable podlove-icon-remove\"></i>\n            </span>\n        </td>\n        <td class=\"move column-move\"><i class=\"reorder-handle podlove-icon-reorder\"></i></td>\n    </tr>\n    </script>\n\n    <script type=\"text/javascript\">\n        var PODLOVE = PODLOVE || {};\n        var i = 0;\n        var existing_contributions = <?php echo wp_json_encode($existing_contributions); ?>;\n\n        PODLOVE.Contributors = <?php echo wp_json_encode(array_values($cjson)); ?>;\n        PODLOVE.Contributors_form_base_name = \"<?php echo $form_base_name; ?>\";\n\n        (function($) {\n            var form_base_name = \"<?php echo $form_base_name; ?>\";\n\n            function update_chosen() {\n                $(\".chosen\").chosen({ width: '100%' });\n                $(\".chosen-image\").chosenImage();\n            }\n\n            function fetch_contributor(contributor_id) {\n                contributor_id = parseInt(contributor_id, 10);\n\n                return $.grep(PODLOVE.Contributors, function(contributor, index) {\n                    return parseInt(contributor.id, 10) === contributor_id;\n                })[0]; // Using [0] as the returned element has multiple indexes\n            }\n\n            function contributor_dropdown_handler() {\n                $('table').on('change', 'select.podlove-contributor-dropdown', function() {\n                    var i;\n                    var contributor = fetch_contributor(this.value);\n                    var row = $(this).closest(\"tr\");\n                    var edit_button   = row.find(\".podlove-contributor-edit\");\n                    var create_button = row.find(\".podlove-contributor-create\");\n\n                    if (this.value == \"create\") {\n                        var create_url = $(this).parent().find(\".podlove-contributor-create\").attr(\"href\");\n                        // show create button, just in case redirect does not work\n                        create_button.show();\n                        edit_button.hide();\n                        // redirect\n                        window.location = create_url;\n                        return;\n                    } else {\n                        create_button.hide();\n                    }\n\n                    // Check for empty contributors / for new field\n                    if( typeof contributor === 'undefined' ) {\n                        row.find(\".podlove-avatar-column\").html(\"\"); // Empty avatar column and hide edit button\n                        row.find(\".podlove-contributor-edit\").hide();\n                        return;\n                    }\n\n                    i = row.data(\"row-number\");\n\n                    // Setting data attribute and avatar field\n                    row.data(\"contributor-id\", contributor.id);\n                    row.find(\".podlove-avatar-column\").html( contributor.avatar );\n                    // Renaming all corresponding elements after the contributor has changed\n                    row.find(\".podlove-contributor-dropdown\").attr(\"name\", PODLOVE.Contributors_form_base_name + \"[\" + i + \"]\" + \"[\" + contributor.id + \"]\" + \"[id]\");\n                    row.find(\".podlove-group\").attr(\"name\", PODLOVE.Contributors_form_base_name + \"[\" + i + \"]\" + \"[\" + contributor.id + \"]\" + \"[group]\");\n                    row.find(\".podlove-role\").attr(\"name\", PODLOVE.Contributors_form_base_name + \"[\" + i + \"]\" + \"[\" + contributor.id + \"]\" + \"[role]\");\n                    row.find(\".podlove-comment\").attr(\"name\", PODLOVE.Contributors_form_base_name + \"[\" + i + \"]\" + \"[\" + contributor.id + \"]\" + \"[comment]\");\n                    edit_button.attr(\"href\", \"<?php echo site_url(); ?>/wp-admin/admin.php?page=podlove_contributor_settings&action=edit&contributor=\" + contributor.id);\n                    edit_button.show(); // Show Edit Button\n                });\n            }\n\n            function contributors_init() {\n                var i = 0;\n\n                contributor_dropdown_handler();\n\n                $(\"#contributors-form table\").podloveDataTable({\n                    rowTemplate: \"#contributor-row-template\",\n                    data: existing_contributions,\n                    dataPresets: PODLOVE.Contributors,\n                    sortableHandle: \".reorder-handle\",\n                    addRowHandle: \"#add_new_contributor_button\",\n                    deleteHandle: \".contributor_remove\",\n                    onRowLoad: function(o) {\n                        o.row = o.row.replace(/\\{\\{contributor-id\\}\\}/g, o.object.id);\n                        o.row = o.row.replace(/\\{\\{id\\}\\}/g, i);\n                        i++;\n                    },\n                    onRowAdd: function(o, init) {\n                        var row = $(\"#contributors_table_body tr:last\");\n\n                        row.find('td.podlove-avatar-column').html(o.object.avatar);\n                        // select contributor in contributor-dropdown\n                        row.find('select.podlove-contributor-dropdown option[value=\"' + o.object.id + '\"]').attr('selected',true);\n                        // select default role\n                        row.find('select.podlove-role option[value=\"' + o.entry.role + '\"]').attr('selected',true);\n                        // select default group\n                        row.find('select.podlove-group option[value=\"' + o.entry.group + '\"]').attr('selected',true);\n                        // set comment\n                        row.find('input.podlove-comment').val(o.entry.comment);\n\n                        // Update Chosen before we focus on the new contributor\n                        update_chosen();\n\n                        // Focus new contributor\n                        if (!init) {\n                            $(\".podlove-contributor-column\").last().find(\".chosen-container a\").focus();\n                        }\n\n                    },\n                    onRowDelete: function(tr) {\n                        var object_id = tr.data(\"object-id\"),\n                                ajax_action = \"podlove-contributors-delete-\";\n\n                        switch (form_base_name) {\n                            case \"podlove_podcast[contributor]\":\n                                ajax_action += \"podcast\";\n                                break;\n                            case \"podlove_contributor_defaults[contributor]\":\n                                ajax_action += \"default\";\n                                break;\n                            default:\n                                console.log(\"Error when deleting social/donation entry: unknows form type '\" + form_base_name + \"'\");\n                        }\n\n                        var data = {\n                            action: ajax_action,\n                            object_id: object_id,\n                            nonce: podlove_admin_global.nonce_ajax\n                        };\n\n                        $.ajax({\n                            url: ajaxurl,\n                            data: data,\n                            dataType: 'json'\n                        });\n                    }\n                });\n            }\n\n            function is_form_ready() {\n                return $(\"#contributors-form table:visible\").length === 1;\n            }\n\n            function when_form_is_ready(callback) {\n                if (is_form_ready()) {\n                    callback();\n                } else {\n                    window.setTimeout(() => {\n                        when_form_is_ready(callback);\n                    }, 50);\n                }\n            }\n\n            $(document).ready(function() {\n                when_form_is_ready(contributors_init)\n            });\n        }(jQuery));\n\n    </script>\n</div>\n"
  },
  {
    "path": "lib/modules/external_analytics/external_analytics.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ExternalAnalytics;\n\nclass External_Analytics extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'External Analytics';\n    protected $module_description = 'Add an external analytics service, e.g. OP3, Podtrac, Blubrry, etc.';\n    protected $module_group = 'external services';\n\n    public function load()\n    {\n        add_action('init', [$this, 'register_hooks']);\n        add_action('init', [$this, 'register_module_option']);\n    }\n\n    public function register_hooks()\n    {\n        $analytics_prefix = $this->get_module_option('analytics_prefix');\n        if (!$analytics_prefix) {\n            return;\n        }\n\n        add_filter('podlove_enclosure_url', function ($original_url) use ($analytics_prefix) {\n            $schemeless_url = preg_replace('/^https?:\\/\\//', '', $original_url);\n\n            return trailingslashit($analytics_prefix).$schemeless_url;\n        });\n    }\n\n    public function register_module_option()\n    {\n        $this->register_option('analytics_prefix', 'string', [\n            'label' => __('Analytics Prefix', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '\n    <p><b>'.__('Examples:', 'podlove-podcasting-plugin-for-wordpress').'</b></p>\n    '.'<ul>\n    '.'<li><a href=\"https://op3.dev/\" target=\"_blank\">Open Podcast Prefix Project (OP3)</a>: https://op3.dev/e/</li>\n    '.'<li><a href=\"https://publisher.podtrac.com\" target=\"_blank\">Podtrac</a>: https://dts.podtrac.com/redirect.mp3/</li>\n    '.'<li><a href=\"https://stats.blubrry.com\" target=\"_blank\">Blubrry</a>: http://media.blubrry.com/{blubrry_id}/</li>\n    <li>'.__('etc.', 'podlove-podcasting-plugin-for-wordpress').'</li>\n    '.'</ul>\n            ',\n            'html' => [\n                'class' => 'regular-text podlove-check-input',\n                'data-podlove-input-type' => 'text',\n                'placeholder' => 'https://op3.dev/e/'\n            ]\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/fyyd/fyyd.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\fyyd;\n\nclass fyyd extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'fyyd';\n    protected $module_description = 'Inserts a verification code into your feeds for the fyyd search engine.';\n    protected $module_group = 'Podcast Directories';\n\n    public function load()\n    {\n        add_action('init', [$this, 'register_hooks']);\n        add_action('init', [$this, 'register_module_option']);\n    }\n\n    public function register_hooks()\n    {\n        $fyyd_verifycode = $this->get_module_option('fyyd_verifycode');\n        if (!$fyyd_verifycode) {\n            return;\n        }\n        add_action('podlove_rss2_head', function ($feed) use ($fyyd_verifycode) {\n            echo \"\\n\\t\".sprintf('<fyyd:verify xmlns:fyyd=\"https://fyyd.de/fyyd-ns/\">%s</fyyd:verify>'.\"\\n\\t\", $fyyd_verifycode);\n        });\n    }\n\n    public function register_module_option()\n    {\n        $this->register_option('fyyd_verifycode', 'string', [\n            'label' => __('fyyd verifycode', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Code to verify your ownership at fyyd', 'podlove-podcasting-plugin-for-wordpress'),\n            'html' => [\n                'class' => 'regular-text podlove-check-input',\n                'data-podlove-input-type' => 'text',\n                'placeholder' => 'yourverifycodehere',\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/export/podcast_exporter.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Export;\n\nclass PodcastExporter\n{\n    public const XML_NAMESPACE = 'http://podlove.org/podlove-podcast-publisher/export';\n    private $compression = false;\n\n    public function __construct()\n    {\n        add_action('podlove_xml_export', [$this, 'exportEpisodes']);\n        add_action('podlove_xml_export', [$this, 'exportAssets']);\n        add_action('podlove_xml_export', [$this, 'exportFeeds']);\n        add_action('podlove_xml_export', [$this, 'exportFileType']);\n        add_action('podlove_xml_export', [$this, 'exportMediaFile']);\n        add_action('podlove_xml_export', [$this, 'exportTemplates']);\n        add_action('podlove_xml_export', [$this, 'exportTracking']);\n        add_action('podlove_xml_export', [$this, 'exportOptions']);\n\n        if (function_exists('gzencode') && extension_loaded('zlib')) {\n            $this->enableCompression();\n        }\n    }\n\n    public static function init()\n    {\n        if (!is_admin()) {\n            return;\n        }\n\n        if (isset($_GET['podlove_export']) && $_GET['podlove_export']) {\n            if (!current_user_can('administrator')) {\n                return;\n            }\n\n            if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_export')) {\n                return;\n            }\n\n            $exporter = new \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter();\n            $exporter->download();\n            exit;\n        }\n    }\n\n    public function enableCompression()\n    {\n        $this->compression = true;\n    }\n\n    public function isCompressionEnabled()\n    {\n        return (bool) $this->compression;\n    }\n\n    public function download()\n    {\n        $this->setDownloadHeaders();\n        $xml = $this->getXml();\n\n        if ($this->isCompressionEnabled()) {\n            echo gzencode($xml);\n        } else {\n            echo $xml;\n        }\n        exit;\n    }\n\n    public function exportEpisodes(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'episodes', 'episode', '\\Podlove\\Model\\Episode');\n    }\n\n    public function exportAssets(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'assets', 'asset', '\\Podlove\\Model\\EpisodeAsset');\n    }\n\n    public function exportFeeds(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'feeds', 'feed', '\\Podlove\\Model\\Feed');\n    }\n\n    public function exportFileType(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'filetypes', 'filetype', '\\Podlove\\Model\\FileType');\n    }\n\n    public function exportMediaFile(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'mediafiles', 'mediafile', '\\Podlove\\Model\\MediaFile');\n    }\n\n    public function exportTemplates(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'templates', 'template', '\\Podlove\\Model\\Template');\n    }\n\n    public function exportTracking(\\SimpleXMLElement $xml)\n    {\n        self::exportTable($xml, 'geoareas', 'geoarea', '\\Podlove\\Model\\GeoArea');\n        self::exportTable($xml, 'geoareanames', 'geoareaname', '\\Podlove\\Model\\GeoAreaName');\n        self::exportTable($xml, 'useragents', 'useragent', '\\Podlove\\Model\\UserAgent');\n    }\n\n    public function exportOptions(\\SimpleXMLElement $xml)\n    {\n        global $wpdb;\n        $sql = 'SELECT option_name FROM '.$wpdb->options.' WHERE option_name LIKE \"%podlove%\" AND option_name NOT LIKE \"_transient%\"';\n        $options = $wpdb->get_col($sql);\n\n        $xml_group = $xml->addChild('xmlns:wpe:options');\n        foreach ($options as $option_name) {\n            $value = get_option($option_name);\n            if ($value !== false) {\n                if (is_array($value)) {\n                    foreach ($value as $k => $v) {\n                        // `addChild` does not escape '&', so we need to escape\n                        // it *before* serializing, otherwise deserialization will\n                        // break due to string length mismatch.\n                        if (is_string($v)) {\n                            $value[$k] = htmlspecialchars($v);\n                        }\n                    }\n                    $xml_group->addChild(\"xmlns:wpe:{$option_name}\", serialize($value));\n                } else {\n                    if (is_object($value)) {\n                        $value = maybe_serialize($value);\n                    }\n\n                    $value = htmlspecialchars($value);\n                    $xml_group->addChild(\"xmlns:wpe:{$option_name}\", $value);\n                }\n            }\n        }\n    }\n\n    public static function exportTable(\\SimpleXMLElement $xml, $group_name, $item_name, $table_class)\n    {\n        $xml_group = $xml->addChild(\"xmlns:wpe:{$group_name}\");\n        foreach ($table_class::all() as $mediafile) {\n            $xml_item = $xml_group->addChild(\"xmlns:wpe:{$item_name}\");\n            foreach ($table_class::property_names() as $property_name) {\n                if (is_null($mediafile->{$property_name}) || strlen($mediafile->{$property_name}) === 0) {\n                    continue;\n                }\n\n                $value = htmlspecialchars($mediafile->{$property_name});\n                $xml_item->addChild(\"xmlns:wpe:{$property_name}\", $value);\n            }\n        }\n    }\n\n    public function getXml()\n    {\n        $xml = new \\SimpleXMLElement('<wpe:export/>', LIBXML_NOERROR | LIBXML_NOWARNING, false, 'wpe', true);\n        // Double xmlns looks strange but is intentionally/required.\n        // See http://stackoverflow.com/a/9391673/72448\n        $xml->addAttribute('xmlns:xmlns:wpe', self::XML_NAMESPACE);\n        $xml->addAttribute('version', '1.0');\n        $xml->addAttribute('podlove-publisher-version', \\Podlove\\get_plugin_header('Version'));\n\n        // add comments\n        $comment = \"\\n\\tExport Date: \".date('r');\n        $comment .= \"\\n\\t\";\n\n        $dom = dom_import_simplexml($xml);\n        $commentElement = $dom->ownerDocument->createComment($comment);\n        $dom->appendChild($commentElement);\n\n        do_action('podlove_xml_export', $xml);\n\n        // return formatted\n        $dom = dom_import_simplexml($xml)->ownerDocument;\n        $dom->formatOutput = true;\n\n        return $dom->saveXML();\n    }\n\n    private function getDownloadFileName()\n    {\n        $sitename = sanitize_key(get_bloginfo('name'));\n\n        if (!empty($sitename)) {\n            $sitename .= '.';\n        }\n\n        $filename = $sitename.'podlove.'.date('Y-m-d').'.xml';\n\n        if ($this->isCompressionEnabled()) {\n            $filename .= '.gz';\n        }\n\n        return $filename;\n    }\n\n    private function setDownloadHeaders()\n    {\n        header('Content-Description: File Transfer');\n        header('Content-Disposition: attachment; filename='.$this->getDownloadFileName());\n        header('Cache-control: private');\n        header('Expires: -1');\n\n        if ($this->isCompressionEnabled()) {\n            // Do *not* send gzip headers. Why? If you set gzip headers, the data is\n            // transferred compressed but unzipped before it's saved to disk. But we\n            // want it to be compressed as a file, not just for transfer.\n\n            // header( 'Content-Encoding: gzip' );\n            // header( 'Content-Type: application/x-gzip; charset=' . get_option( 'blog_charset' ), true );\n        } else {\n            header('Content-Type: text/xml; charset='.get_option('blog_charset'), true);\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/export/tracking_exporter.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Export;\n\nclass TrackingExporter\n{\n    public static function init()\n    {\n        add_action('wp_ajax_podlove-export-tracking', [__CLASS__, 'export_tracking']);\n        add_action('wp_ajax_podlove-export-tracking-status', [__CLASS__, 'export_tracking_status']);\n\n        self::init_download();\n    }\n\n    public static function init_download()\n    {\n        if (!is_admin()) {\n            return;\n        }\n\n        if (isset($_GET['podlove_export_tracking']) && $_GET['podlove_export_tracking']) {\n            if (!current_user_can('administrator')) {\n                exit;\n            }\n\n            if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_export_tracking_download')) {\n                exit;\n            }\n\n            delete_transient('podlove_tracking_export_finished');\n\n            header('Content-Type: application/octet-stream');\n            header('Content-Description: File Transfer');\n            header('Content-Disposition: attachment; filename='.TrackingExporter::getDownloadFileName());\n            header('Cache-control: private');\n            header('Expires: -1');\n\n            readfile(TrackingExporter::get_tracking_export_file_path());\n            exit;\n        }\n    }\n\n    public static function get_tracking_export_file_path()\n    {\n        $upload_dir = wp_upload_dir();\n\n        return $upload_dir['basedir'].DIRECTORY_SEPARATOR.'tracking.tmp';\n    }\n\n    public static function export_tracking()\n    {\n        global $wpdb;\n\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_export_tracking')) {\n            exit;\n        }\n\n        // only one export at a time\n        if (get_option('podlove_tracking_export_all') !== false) {\n            exit;\n        }\n\n        update_option('podlove_tracking_export_all', $wpdb->get_var('SELECT COUNT(*) FROM '.\\Podlove\\Model\\DownloadIntent::table_name()));\n        update_option('podlove_tracking_export_progress', 0);\n\n        $rowsPerQuery = 1000;\n        $lastId = 0;\n        $page = 0;\n\n        $fp = gzopen(self::get_tracking_export_file_path(), 'w');\n\n        do {\n            // Keeping track of the $lastId is (roughly) a bajillion times faster than paging via LIMIT.\n            $sql = '\n\t\t\t\tSELECT\n\t\t\t\t\tid,\n\t\t\t\t\tuser_agent_id,\n\t\t\t\t\tmedia_file_id,\n\t\t\t\t\trequest_id,\n\t\t\t\t\taccessed_at,\n\t\t\t\t\tsource,\n\t\t\t\t\tcontext,\n\t\t\t\t\tgeo_area_id,\n\t\t\t\t\tlat,\n\t\t\t\t\tlng,\n\t\t\t\t\thttprange\n\t\t\t\tFROM\n\t\t\t\t\t'.\\Podlove\\Model\\DownloadIntent::table_name().'\n\t\t\t\t\tWHERE id > '.(int) $lastId.\"\n\t\t\t\tLIMIT 0, {$rowsPerQuery}\";\n            $rows = $wpdb->get_results($sql, ARRAY_A);\n            foreach ($rows as $row) {\n                gzwrite($fp, implode(',', $row).\"\\n\");\n            }\n\n            $lastId = $row['id'];\n            ++$page;\n\n            update_option('podlove_tracking_export_progress', $page * $rowsPerQuery);\n        } while (count($rows) > 0);\n\n        gzclose($fp);\n\n        set_transient('podlove_tracking_export_finished', true, MINUTE_IN_SECONDS * 3);\n        delete_option('podlove_tracking_export_all');\n        delete_option('podlove_tracking_export_progress');\n        exit;\n    }\n\n    public static function export_tracking_status()\n    {\n        echo wp_json_encode([\n            'all' => get_option('podlove_tracking_export_all'),\n            'progress' => get_option('podlove_tracking_export_progress'),\n            'finished' => (bool) get_transient('podlove_tracking_export_finished'),\n        ]);\n        exit;\n    }\n\n    private static function getDownloadFileName()\n    {\n        $sitename = sanitize_key(get_bloginfo('name'));\n\n        if (!empty($sitename)) {\n            $sitename .= '.';\n        }\n\n        return $sitename.'tracking.'.date('Y-m-d').'.csv.gz';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_assets_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportAssetsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Assets';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Assets';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\EpisodeAsset';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'asset';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_episodes_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Log;\nuse Podlove\\Model;\n\nclass PodcastImportEpisodesJob\n{\n    use JobTrait;\n    use PodcastImportJobTrait;\n\n    public function setup()\n    {\n        $this->setupXml();\n        $this->hooks['init'] = [$this, 'init_job'];\n    }\n\n    public static function title()\n    {\n        return 'Podcast Import: Episodes';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Episodes';\n    }\n\n    public function init_job()\n    {\n        Model\\Episode::delete_all();\n        $this->job->state = 0;\n    }\n\n    public function get_total_steps()\n    {\n        return count($this->xml->xpath('//wpe:episode'));\n    }\n\n    protected function do_step()\n    {\n        $episode = $this->xml->xpath('//wpe:episode')[$this->job->state];\n\n        $new_episode = new Model\\Episode();\n\n        foreach ($episode->children('wpe', true) as $attribute) {\n            if ($attribute->getName() == 'chapters') {\n                $new_episode->chapters = str_replace('&#xD;', \"\\r\\n\", $attribute);\n            } else {\n                $new_episode->{$attribute->getName()} = self::escape((string) $attribute);\n            }\n        }\n\n        if ($new_post_id = $this->getNewPostId($new_episode->post_id)) {\n            $new_episode->post_id = $new_post_id;\n            $new_episode->save();\n            Log::get()->addInfo(sprintf('Import post %d (%s)', $new_post_id, $new_episode->post_title));\n        } else {\n            Log::get()->addWarning('Importer: no matching post for (old) post_id='.$new_episode->post_id);\n        }\n\n        ++$this->job->state;\n\n        return 1;\n    }\n\n    /**\n     * Get mapping for post id after post import.\n     *\n     * When importing posts, their IDs might change.\n     * This function maps an existing post id to the new one.\n     *\n     * @param int $old_post_id\n     *\n     * @return null|int post_id on success, otherwise null\n     */\n    private function getNewPostId($old_post_id)\n    {\n        $query_for_post_id = new \\WP_Query([\n            'post_type' => 'podcast',\n            'meta_query' => [\n                [\n                    'key' => 'import_id',\n                    'value' => $old_post_id,\n                    'compare' => '=',\n                ],\n            ],\n        ]);\n\n        if ($query_for_post_id->have_posts()) {\n            $p = $query_for_post_id->next_post();\n\n            return $p->ID;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_feeds_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportFeedsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Feeds';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Feeds';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\Feed';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'feed';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_filetypes_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportFiletypesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: File Types';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast File Types';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\FileType';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'filetype';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_job_table_trait.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\ntrait PodcastImportJobTableTrait\n{\n    public function setup()\n    {\n        $this->setupXml();\n        $this->hooks['init'] = [$this, 'init_job'];\n    }\n\n    public function init_job()\n    {\n        $table = self::get_import_table_class();\n        $table::delete_all();\n\n        $this->job->state = 0;\n    }\n\n    public function get_total_steps()\n    {\n        return count($this->get_xml_group());\n    }\n\n    /**\n     * Fully qualified name of the model class.\n     *\n     * Example: '\\Podlove\\Model\\EpisodeAsset'\n     *\n     * @return string\n     */\n    abstract protected static function get_import_table_class();\n\n    /**\n     * Name of the group in export file.\n     *\n     * Example: 'asset'\n     *\n     * @return string\n     */\n    abstract protected static function get_import_item_name();\n\n    protected function get_xml_group()\n    {\n        return $this->xml->xpath('//wpe:'.self::get_import_item_name());\n    }\n\n    protected function do_step()\n    {\n        $group = $this->get_xml_group();\n        $item = $group[$this->job->state];\n\n        $table = self::get_import_table_class();\n        $new_item = new $table();\n        foreach ($item->children('wpe', true) as $attribute) {\n            $new_item->{$attribute->getName()} = (string) $attribute;\n        }\n        $new_item->save();\n\n        ++$this->job->state;\n\n        return 1;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_job_trait.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Modules\\ImportExport\\Export\\PodcastExporter;\n\ntrait PodcastImportJobTrait\n{\n    protected $xml;\n\n    private function setupXml()\n    {\n        $file = get_option('podlove_import_file');\n\n        $gzFileHandler = gzopen($file, 'r');\n        $decompressed = gzread($gzFileHandler, self::gzfilesize($file));\n        gzclose($gzFileHandler);\n\n        $this->xml = simplexml_load_string($decompressed);\n\n        $this->xml->registerXPathNamespace('wpe', PodcastExporter::XML_NAMESPACE);\n    }\n\n    private static function gzfilesize($filename)\n    {\n        if (($zp = fopen($filename, 'r')) !== false) {\n            if (@fread($zp, 2) == \"\\x1F\\x8B\") { // this is a gzip'd file\n                fseek($zp, -4, SEEK_END);\n                if (strlen($datum = @fread($zp, 4)) == 4) {\n                    extract(unpack('Vgzfs', $datum));\n                }\n            } else { // not a gzip'd file, revert to regular filesize function\n                $gzfs = filesize($filename);\n            }\n            fclose($zp);\n        }\n\n        return $gzfs;\n    }\n\n    private static function escape($value)\n    {\n        global $wpdb;\n        $wpdb->escape_by_ref($value);\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_mediafiles_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportMediafilesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Media Files';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Media Files';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\MediaFile';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'mediafile';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_options_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\CronJobRunner;\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportOptionsJob\n{\n    use JobTrait;\n    use PodcastImportJobTrait;\n\n    public function setup()\n    {\n        $this->setupXml();\n        $this->hooks['init'] = [$this, 'init_job'];\n        $this->hooks['finished'] = [$this, 'init_additional_jobs'];\n    }\n\n    public static function title()\n    {\n        return 'Podcast Import: Options';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Options';\n    }\n\n    public function init_job()\n    {\n        $this->job->state = 0;\n    }\n\n    /**\n     * Initialize additional jobs via hook.\n     *\n     * Jobs registered by modules can only be run after options are imprted\n     * because modules must be active for import hooks to be registered.\n     */\n    public function init_additional_jobs()\n    {\n        $jobs = apply_filters('podlove_import_jobs', []);\n\n        if (is_array($jobs) && count($jobs) > 0) {\n            foreach ($jobs as $job) {\n                CronJobRunner::create_job($job);\n            }\n        }\n    }\n\n    public function get_total_steps()\n    {\n        return count($this->xml->xpath('//wpe:options')[0]->children('wpe', true));\n    }\n\n    protected function do_step()\n    {\n        $options = (array) $this->xml->xpath('//wpe:options')[0]->children('wpe', true);\n\n        $keys = array_keys($options);\n        $key = $keys[$this->job->state];\n\n        $option = $options[$key];\n\n        $option_string = (string) $option;\n\n        // Replace lone '&' characters with '&amp;'.\n        // Why? When exporting, the same conversion needs to be done to\n        // make strings XML compatible. When importing, it is automatically\n        // converted back to '&' which breaks `maybe_unserialize` (because\n        // it changes the length of the content). So we need to convert it back.\n        if (strpos($option_string, '&') !== false) {\n            $option_string = preg_replace('/&([^#])(?![a-z1-4]{1,8};)/i', '&amp;$1', $option_string);\n        }\n\n        $skip_options = [\n            'podlove_import_file',\n            'podlove_repair_log',\n            'podlove_cron_diagnosis',\n            'podlove_cron_diagnosis_tries',\n            'podlove_global_messages',\n        ];\n\n        if (!in_array($key, $skip_options)) {\n            update_option($key, maybe_unserialize($option_string));\n        }\n\n        ++$this->job->state;\n\n        return 1;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_templates_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportTemplatesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Templates';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Templates';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\Template';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'template';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_tracking_area_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportTrackingAreaJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Tracking Areas';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Tracking Areas';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\GeoArea';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'geoarea';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_tracking_area_name_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportTrackingAreaNameJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Tracking Area Names';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Tracking Area Names';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\GeoAreaName';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'geoareaname';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_import_user_agents_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImportUserAgentsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: User Agents';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast User Agents';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Model\\UserAgent';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'useragent';\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_importer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\CronJobRunner;\nuse Podlove\\Model;\nuse Podlove\\Model\\Job;\n\nclass PodcastImporter\n{\n    // path to import file\n    private $file;\n\n    // SimpleXML document of import file\n    private $xml;\n\n    public static function init()\n    {\n        add_action('wp_ajax_podlove-import-status', [__CLASS__, 'ajax_render_status']);\n\n        if (!is_admin()) {\n            return;\n        }\n\n        if (isset($_REQUEST['page']) && $_REQUEST['page'] == 'podlove_tools_settings_handle') {\n            add_action('admin_notices', [__CLASS__, 'render_import_progress']);\n        }\n\n        if (!isset($_FILES['podlove_import'])) {\n            return;\n        }\n\n        if (!current_user_can('administrator')) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_import')) {\n            return;\n        }\n\n        // allow xml+gz uploads\n        add_filter('mime_types', function ($mimes) {\n            $mimes['xml'] = 'application/xml';\n            $mimes['gz'] = 'application/gzip';\n\n            return $mimes;\n        });\n\n        add_filter('upload_mimes', function ($mimes_types) {\n            $mimes_types['gz'] = 'application/x-gzip';\n\n            return $mimes_types;\n        }, 99);\n\n        add_filter('wp_check_filetype_and_ext', function ($types, $file, $filename, $mimes) {\n            $wp_filetype = wp_check_filetype($filename, $mimes);\n            $ext = $wp_filetype['ext'];\n            $type = $wp_filetype['type'];\n            if (in_array($ext, ['gz'])) {\n                $types['ext'] = $ext;\n                $types['type'] = $type;\n            }\n\n            return $types;\n        }, 99, 4);\n\n        require_once ABSPATH.'/wp-admin/includes/file.php';\n\n        $file = wp_handle_upload($_FILES['podlove_import'], [\n            'test_form' => false,\n            'mimes' => [\n                'xml' => 'application/xml',\n                'gz' => 'application/gzip',\n            ],\n        ]);\n\n        if (isset($file['error'])) {\n            print_r($file['error']);\n            exit;\n        }\n\n        update_option('podlove_import_file', $file['file']);\n        if (!($file = get_option('podlove_import_file'))) {\n            return;\n        }\n\n        // delete all jobs before starting import\n        Model\\Job::delete_all();\n\n        foreach (self::get_import_job_classes() as $job) {\n            CronJobRunner::create_job($job);\n        }\n\n        $redirect_url = 'admin.php?page=podlove_tools_settings_handle';\n        wp_redirect(admin_url($redirect_url));\n        exit;\n    }\n\n    public static function get_import_job_classes()\n    {\n        $jobs = [\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportEpisodesJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportOptionsJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportAssetsJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportFeedsJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportFiletypesJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportMediafilesJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportTrackingAreaJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportTrackingAreaNameJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportUserAgentsJob',\n            '\\Podlove\\Modules\\ImportExport\\Import\\PodcastImportTemplatesJob',\n        ];\n\n        return apply_filters('podlove_import_jobs', $jobs);\n    }\n\n    public static function ajax_render_status()\n    {\n        self::render_import_progress_jobs();\n        exit;\n    }\n\n    public static function render_import_progress()\n    {\n        $jobs = self::get_import_job_classes();\n\n        $unfinished = array_reduce($jobs, function ($jobs, $job) {\n            if (Job::find_one_recent_unfinished_job($job)) {\n                $jobs[] = $job;\n            }\n\n            return $jobs;\n        }, []);\n\n        if (count($unfinished) < 1) {\n            return;\n        } ?>\n\t\t<div class=\"updated\" id=\"podlove-import-status\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podcast Import', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</p>\n\t\t\t<?php self::render_import_progress_jobs(); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public static function render_import_progress_jobs()\n    {\n        $jobs = self::get_import_job_classes();\n\n        $finished = array_reduce($jobs, function ($jobs, $job) {\n            if (Job::find_one_recent_finished_job($job)) {\n                $jobs[] = $job;\n            }\n\n            return $jobs;\n        }, []);\n\n        $all_count = count($jobs);\n        $finished_count = count($finished); ?>\n\t\t<div class=\"podlove-import-status-progress\">\n\t\t<?php if ($all_count == $finished_count) { ?>\n\t\t\t<p>\n\t\t\t\t<em><?php echo __('Import finished!', 'podlove-podcasting-plugin-for-wordpress'); ?></em>\n\t\t\t</p>\n\t\t<?php } else { ?>\n\t\t\t<p>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('Total import progress: %d/%d', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t    $finished_count,\n\t\t\t\t    $all_count\n\t\t\t\t); ?>\n\t\t\t</p>\n\t\t\t<?php foreach ($jobs as $jobClass) { ?>\n\t\t\t\t<?php $job = Job::find_one_recent_unfinished_job($jobClass); ?>\n\t\t\t\t\t<?php if ($job && $job->steps_progress > 0) { ?>\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('Currently working on: %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    $jobClass::title()\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t<?php if ($job->steps_progress > 0) { ?>\n\t\t\t\t\t\t\t<i class=\"clickable podlove-icon-spinner rotate\"></i>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t<?php } ?>\n\t\t\t<?php } ?>\n\t\t<?php } ?>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/podcast_importer_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\JobTrait;\n\nclass PodcastImporterJob\n{\n    use JobTrait;\n\n    // path to import file\n    private $file;\n\n    // SimpleXML document of import file\n    private $xml;\n\n    public function setup()\n    {\n        $this->hooks['init'] = [$this, 'init_job'];\n    }\n\n    public static function title()\n    {\n        return 'Podcast Importer';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Settings';\n    }\n\n    public function init_job()\n    {\n        $this->job->state = [\n            // '0' for 'to do', '1' for 'done'\n            'actions' => [\n                'episodes' => 0,\n                'options' => 0,\n                'filetypes' => 0,\n                'assets' => 0,\n                'feeds' => 0,\n                'mediafiles' => 0,\n                'tracking_area' => 0,\n                'tracking_areaname' => 0,\n                'tracking_useragent' => 0,\n                'templates' => 0,\n                'other' => 0,\n                'migrations' => 0,\n            ],\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return count($this->job->state['actions']);\n    }\n\n    protected function do_step()\n    {\n        // fetch next action\n        $actions_left = array_filter($this->job->state['actions'], function ($x) {\n            return $x < 1;\n        });\n        $next_action = array_keys($actions_left)[0];\n\n        $importer = new \\Podlove\\Modules\\ImportExport\\Import\\PodcastImporter(get_option('podlove_import_file'));\n\n        switch ($next_action) {\n            case 'episodes':\n                $importer->importEpisodes();\n\n                break;\n            case 'options':\n                $importer->importOptions();\n\n                break;\n            case 'filetypes':\n                $importer->importFileTypes();\n\n                break;\n            case 'assets':\n                $importer->importAssets();\n\n                break;\n            case 'feeds':\n                $importer->importFeeds();\n\n                break;\n            case 'mediafiles':\n                $importer->importMediaFiles();\n\n                break;\n            case 'tracking_area':\n                $importer->importTrackingArea();\n\n                break;\n            case 'tracking_areaname':\n                $importer->importTrackingAreaName();\n\n                break;\n            case 'tracking_useragent':\n                $importer->importTrackingUserAgent();\n\n                break;\n            case 'templates':\n                $importer->importTemplates();\n\n                break;\n            case 'other':\n                $importer->importOther();\n\n                break;\n            case 'migrations':\n                \\Podlove\\run_database_migrations();\n\n                break;\n        }\n\n        // mark action as done\n        $state = $this->job->state;\n        $state['actions'][$next_action] = 1;\n        $this->job->state = $state;\n\n        return 1;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/tracking_importer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\CronJobRunner;\n\nclass TrackingImporter\n{\n    // path to import file\n    private $file;\n\n    public function __construct($file)\n    {\n        $this->file = $file;\n    }\n\n    public static function init()\n    {\n        if (!is_admin()) {\n            return;\n        }\n\n        if (!isset($_FILES['podlove_import_tracking'])) {\n            return;\n        }\n\n        if (!current_user_can('administrator')) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_import_tracking')) {\n            return;\n        }\n\n        set_time_limit(10 * MINUTE_IN_SECONDS);\n\n        add_filter('upload_mimes', function ($mimes_types) {\n            $mimes_types['gz'] = 'application/x-gzip';\n\n            return $mimes_types;\n        }, 99);\n\n        add_filter('wp_check_filetype_and_ext', function ($types, $file, $filename, $mimes) {\n            $wp_filetype = wp_check_filetype($filename, $mimes);\n            $ext = $wp_filetype['ext'];\n            $type = $wp_filetype['type'];\n            if (in_array($ext, ['gz'])) {\n                $types['ext'] = $ext;\n                $types['type'] = $type;\n            }\n\n            return $types;\n        }, 99, 4);\n\n        require_once ABSPATH.'/wp-admin/includes/file.php';\n\n        $file = wp_handle_upload($_FILES['podlove_import_tracking'], ['test_form' => false]);\n        if ($file && (!isset($file['error']) || !$file['error'])) {\n            update_option('podlove_import_tracking_file', $file['file']);\n            if (!($file = get_option('podlove_import_tracking_file'))) {\n                return;\n            }\n\n            CronJobRunner::create_job('\\Podlove\\Modules\\ImportExport\\Import\\TrackingImporterJob');\n        } else {\n            echo '<div class=\"error\"><p>'.$file['error'].'</p></div>';\n        }\n\n        add_action('admin_notices', [__CLASS__, 'print_notice']);\n    }\n\n    public static function print_notice()\n    {\n        ?>\n\t\t<div class=\"updated\">\n\t\t\t<p>\n\t\t\t\t<strong>\n\t\t\t\t\t<?php echo __('Tracking Import Started', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</strong>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('See \"Background Jobs\" section below for progress.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import/tracking_importer_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport\\Import;\n\nuse Podlove\\Jobs\\CronJobRunner;\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Model;\n\nclass TrackingImporterJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->hooks['init'] = [$this, 'init_job'];\n        $this->hooks['finished'] = [$this, 'recalculate_analytics'];\n    }\n\n    public static function title()\n    {\n        return 'Podcast Tracking Importer';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Analytics';\n    }\n\n    public function recalculate_analytics()\n    {\n        $jobs = [\n            '\\Podlove\\Jobs\\DownloadIntentCleanupJob' => ['delete_all' => true],\n            '\\Podlove\\Jobs\\DownloadTimedAggregatorJob' => ['force' => true],\n        ];\n\n        foreach ($jobs as $job => $args) {\n            CronJobRunner::create_job($job, $args);\n        }\n    }\n\n    public function init_job()\n    {\n        Model\\DownloadIntent::delete_all();\n        Model\\DownloadIntentClean::delete_all();\n\n        $this->job->state = [\n            'offset' => 0,\n        ];\n    }\n\n    public function get_total_steps()\n    {\n        return $this->get_lines_in_file();\n    }\n\n    protected function do_step()\n    {\n        $fp = gzopen($this->get_file(), 'r');\n        $batchSize = 1000;\n        $batch = [];\n\n        $offset = (int) $this->job->state['offset'];\n\n        if ($offset) {\n            gzseek($fp, $offset, SEEK_SET);\n        }\n\n        while (!gzeof($fp) && count($batch) < $batchSize) {\n            $line = gzgets($fp);\n\n            list(\n                $id,\n                $user_agent_id,\n                $media_file_id,\n                $request_id,\n                $accessed_at,\n                $source,\n                $context,\n                $geo_area_id,\n                $lat,\n                $lng,\n                $httprange\n            ) = array_map(function ($value) {\n                return trim($value);\n            }, explode(',', $line));\n\n            $batch[] = [\n                $user_agent_id,\n                $media_file_id,\n                $request_id,\n                $accessed_at,\n                $source,\n                $context,\n                $geo_area_id,\n                $lat,\n                $lng,\n                $httprange,\n            ];\n        }\n\n        $offset = gztell($fp);\n\n        gzclose($fp);\n\n        self::save_batch_to_db($batch);\n\n        $state = $this->job->state;\n        $state['offset'] = $offset;\n        $this->job->state = $state;\n\n        return count($batch);\n    }\n\n    private static function save_batch_to_db($batch)\n    {\n        global $wpdb;\n\n        $sqlTemplate = '\n\t\t\tINSERT INTO\n\t\t\t\t'.Model\\DownloadIntent::table_name().' \n\t\t\t( `user_agent_id`, `media_file_id`, `request_id`, `accessed_at`, `source`, `context`, `geo_area_id`, `lat`, `lng`, `httprange`) \n\t\t\tVALUES %s';\n\n        if (count($batch)) {\n            $inserts = implode(',', array_map(function ($row) {\n                return '('.implode(',', array_map(function ($x) {\n                    return '\"'.$x.'\"';\n                }, $row)).')';\n            }, $batch));\n            $sql = sprintf($sqlTemplate, $inserts);\n            $wpdb->query($sql);\n        }\n    }\n\n    private function get_file()\n    {\n        return get_option('podlove_import_tracking_file');\n    }\n\n    private function get_lines_in_file()\n    {\n        $linecount = 0;\n        $handle = gzopen($this->get_file(), 'r');\n        while (!gzeof($handle)) {\n            $line = gzgets($handle);\n            ++$linecount;\n        }\n\n        gzclose($handle);\n\n        error_log(print_r(\"linecount: {$linecount} (\".$this->get_file().')', true));\n\n        return $linecount;\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/import_export.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ImportExport;\n\nclass Import_Export extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Import &amp; Export';\n    protected $module_description = 'Import &amp; export podlove data for backup or migration to another WordPress instance.';\n    protected $module_group = 'system';\n\n    public function load()\n    {\n        add_action('admin_init', function () {\n            Export\\PodcastExporter::init();\n            Import\\PodcastImporter::init();\n            Export\\TrackingExporter::init();\n            Import\\TrackingImporter::init();\n            $this->register_tools();\n        });\n\n        add_action('admin_enqueue_scripts', function () {\n            if (isset($_REQUEST['page']) && $_REQUEST['page'] === 'podlove_tools_settings_handle') {\n                wp_enqueue_script('podlove_admin_import_script', $this->get_module_url().'/js/import.js', ['jquery'], \\Podlove\\get_plugin_header('Version'));\n            }\n        });\n\n        add_action('admin_notices', function () {\n            if (!isset($_GET['page'])) {\n                return false;\n            }\n\n            if ($_GET['page'] != 'podlove_tools_settings_handle') {\n                return false;\n            }\n\n            if (!isset($_GET['status'])) {\n                return false;\n            } ?>\n\t\t\t<div class=\"updated\">\n\t\t\t\t<p>\n\t\t\t\t\t<?php\n                    switch ($_GET['status']) {\n                        case 'success':\n                            echo __('Import successful. Happy podcasting!');\n\n                            break;\n                        case 'version-warning':\n                            echo __('Heads up: Your export file was exported from a Publisher with a different version. If possible, both Publisher versions should be identical. However, that might not be a problem. Happy podcasting!');\n\n                            break;\n                    } ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        });\n    }\n\n    public function register_tools()\n    {\n        \\Podlove\\add_tools_section(\n            'import-export',\n            __('Import & Export', 'podlove-podcasting-plugin-for-wordpress'),\n            function () {\n                if (defined('SAVEQUERIES') && SAVEQUERIES) {\n                    ?>\n\t\t\t\t\t<div class=\"error\">\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t<b><?php echo __('Heads up!', 'podlove-podcasting-plugin-for-wordpress'); ?></b>\n\t\t\t\t\t\t\t<?php echo __('The WordPress debug option <code>SAVEQUERIES</code> is active. This might lead to memory issues when exporting or importing tracking data.<br>It is probably defined in <code>wp-config.php</code>. Please turn it off before using the export tool.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<?php\n                }\n            }\n        );\n\n        \\Podlove\\add_tools_field(\n            'export-podcast',\n            __('Podcast Export', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'tools_podcast_export'],\n            'import-export'\n        );\n\n        \\Podlove\\add_tools_field(\n            'export-tracking',\n            __('Tracking Export', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'tools_tracking_export'],\n            'import-export'\n        );\n\n        \\Podlove\\add_tools_field(\n            'import-podcast',\n            __('Podcast Import', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'tools_podcast_import'],\n            'import-export'\n        );\n\n        \\Podlove\\add_tools_field(\n            'import-tracking',\n            __('Tracking Import', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'tools_tracking_import'],\n            'import-export'\n        );\n    }\n\n    public function tools_podcast_export()\n    {\n        echo sprintf(\n            __('This export complements the existing %sWordPress export tool%s. It contains all relevant podcast data to enable you to move from this WordPress instance to another. Step by step:', 'podlove-podcasting-plugin-for-wordpress'),\n            '<a href=\"'.admin_url('export.php').'\">',\n            '</a>'\n        ); ?>\n\t\t<ol>\n\t\t\t<li>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('Go to the %sWordPress export tool%s and export all data.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t    '<a href=\"'.admin_url('export.php').'\">',\n\t\t\t\t    '</a>'\n\t\t\t\t); ?>\n\t\t\t</li>\n\t\t\t<li><?php echo __('Import this file to your new WordPress instance.', 'podlove-podcasting-plugin-for-wordpress'); ?></li>\n\t\t\t<li><?php echo __('Use the button below to export the podcast data file.', 'podlove-podcasting-plugin-for-wordpress'); ?></li>\n\t\t\t<li><?php echo __('In your new WordPress instance, import that file.', 'podlove-podcasting-plugin-for-wordpress'); ?></li>\n\t\t</ol>\n\n\t\t<a href=\"?podlove_export=1&_podlove_nonce=<?php echo wp_create_nonce('podlove_export'); ?>\" class=\"button\"><?php echo __('Export Podcast Data', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t<?php\n    }\n\n    public function tools_tracking_export()\n    {\n        ?>\n\t\t<p>\n\t\t\t<?php echo __('Im- and export of tracking data is a separate task. After you have completed the steps above, you can ex- and import tracking data.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<p>\n\t\t\t<button id=\"podlove_tracking_export\" class=\"button\"><?php echo __('Export Tracking Data', 'podlove-podcasting-plugin-for-wordpress'); ?></button>\n\t\t\t<span id=\"podlove_tracking_export_status_wrapper\">\n\t\t\t\t<?php echo __('Export', 'podlove-podcasting-plugin-for-wordpress'); ?>: <span id=\"podlove_tracking_export_status\">starting ...</span>\n\t\t\t</span>\n\t\t</p>\n\n\t\t<style type=\"text/css\">\n\t\t#podlove_tracking_export_status_wrapper {\n\t\t\tdisplay: none;\n\t\t}\n\t\t</style>\n\n\t\t<script type=\"text/javascript\">\n\t\t(function($) {\n\n\t\t\tvar timeoutID = null;\n\n\t\t\tvar podlove_check_export_status = function() {\n\n\t\t\t\tif (timeoutID) {\n\t\t\t\t\twindow.clearTimeout(timeoutID);\n\t\t\t\t}\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: {action: 'podlove-export-tracking-status'},\n\t\t\t\t\tdataType: 'json',\n\t\t\t\t\tsuccess: function(result) {\n\t\t\t\t\t\tif (result.all && result.progress) {\n\t\t\t\t\t\t\t$(\"#podlove_tracking_export\").attr('disabled', 'disabled');\n\t\t\t\t\t\t\t$(\"#podlove_tracking_export_status\").html((Math.round(1000.0 * (result.progress / result.all))/10.0) + \"%\");\n\t\t\t\t\t\t\t$(\"#podlove_tracking_export_status_wrapper\").show();\n\n\t\t\t\t\t\t\ttimeoutID = window.setTimeout(podlove_check_export_status, 1000);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (result.finished) {\n\t\t\t\t\t\t\t$(\"#podlove_tracking_export\").attr('disabled', false);\n\t\t\t\t\t\t\t$(\"#podlove_tracking_export_status_wrapper\").hide();\n\t\t\t\t\t\t\twindow.location = window.location + \"&podlove_export_tracking=1&_podlove_nonce=<?php echo wp_create_nonce('podlove_export_tracking_download'); ?>\";\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t};\n\n\t\t\t$(\"#podlove_tracking_export\").on(\"click\", function() {\n\n\t\t\t\t$(\"#podlove_tracking_export\").attr('disabled', 'disabled');\n\t\t\t\t$(\"#podlove_tracking_export_status_wrapper\").show();\n\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: ajaxurl,\n\t\t\t\t\tdata: {action: 'podlove-export-tracking', _podlove_nonce: '<?php echo wp_create_nonce('podlove_export_tracking'); ?>'},\n\t\t\t\t\tdataType: 'json'\n\t\t\t\t}).done(function(result) {\n\t\t\t\t\t\tconsole.log(\"tracking export finished\");\n\t\t\t\t    window.setTimeout(podlove_check_export_status, 2000);\n\t\t\t\t});\n\n\t\t\t});\n\n\t\t\t// start immediately, in case the user refreshes the page\n\t\t\tpodlove_check_export_status();\n\t\t}(jQuery));\n\t\t</script>\n\t\t<?php\n    }\n\n    public function tools_podcast_import()\n    {\n        ?>\n\t\t<p>\n\t\t\t<?php echo __('Use this import on <strong>fresh installs only</strong>! Otherwise you may lose data. In any case, you should have backups.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\n\t\t<form method=\"POST\" enctype=\"multipart/form-data\">\n\t\t\t(<span><?php echo self::get_maximum_upload_size_text(); ?></span>)\n\t\t\t<input type=\"file\" name=\"podlove_import\"/>\n\t\t\t<input type=\"submit\" value=\"<?php echo __('Import Podcast Data', 'podlove-podcasting-plugin-for-wordpress'); ?>\" class=\"button\" />\n\t\t\t<?php wp_nonce_field('podlove_import', '_podlove_nonce'); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    public function tools_tracking_import()\n    {\n        ?>\n\t\t<form method=\"POST\" enctype=\"multipart/form-data\">\n\t\t\t(<span><?php echo self::get_maximum_upload_size_text(); ?></span>)\n\t\t\t<input type=\"file\" name=\"podlove_import_tracking\"/>\n\t\t\t<input type=\"submit\" value=\"<?php echo __('Import Tracking Data', 'podlove-podcasting-plugin-for-wordpress'); ?>\" class=\"button\" />\n\t\t\t<?php wp_nonce_field('podlove_import_tracking', '_podlove_nonce'); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    public static function get_maximum_upload_size_text()\n    {\n        // this is exactly the same way it is done in wp_import_upload_form()\n        $bytes = apply_filters('import_upload_size_limit', \\wp_max_upload_size());\n        $size = \\size_format($bytes);\n\n        return sprintf(__('Maximum size: %s'), $size);\n    }\n}\n"
  },
  {
    "path": "lib/modules/import_export/js/import.js",
    "content": "(function ($) {\n\n    var $status_wrapper = $(\"#podlove-import-status\");\n\n    if (!$status_wrapper) {\n        return;\n    }\n\n    var refreshImportStatus = function() {\n        $.ajax({\n            url: ajaxurl,\n            data: {\n                action: 'podlove-import-status'\n            }\n        }).done(function (result) {\n            $(\"#podlove-import-status .podlove-import-status-progress:first\").replaceWith($(result));\n            window.setTimeout(refreshImportStatus, 4000);\n        });\n    };\n\n    window.setTimeout(refreshImportStatus, 100);\n\n})(jQuery);\n"
  },
  {
    "path": "lib/modules/logging/log_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Logging;\n\nuse Podlove\\Model;\n\nclass LogTable extends Model\\Base\n{\n    /**\n     * Only keep logs for 4 weeks.\n     */\n    public static function cleanup()\n    {\n        global $wpdb;\n\n        $wpdb->query('DELETE FROM '.LogTable::table_name().' WHERE time < '.strtotime('-4 weeks'));\n    }\n}\n\nLogTable::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nLogTable::property('channel', 'VARCHAR(255)');\nLogTable::property('level', 'INTEGER');\nLogTable::property('message', 'LONGTEXT');\nLogTable::property('context', 'LONGTEXT');\nLogTable::property('time', 'INTEGER UNSIGNED');\n"
  },
  {
    "path": "lib/modules/logging/logging.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Logging;\n\nuse Podlove\\Log;\nuse Podlove\\Model;\n\nclass Logging extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Logging';\n    protected $module_group = 'system';\n\n    public function get_module_description()\n    {\n        $support_menu_label = __('Support', 'podlove-podcasting-plugin-for-wordpress');\n\n        return 'View podlove related logs at Podlove > '.$support_menu_label.'. (writes logs to database)';\n    }\n\n    public function load()\n    {\n        add_action('podlove_module_was_activated_logging', [$this, 'was_activated']);\n        add_action('init', [$this, 'register_database_logger']);\n\n        if (current_user_can('administrator')) {\n            add_action('podlove_support_page_footer', [$this, 'dashoard_template']);\n        }\n\n        self::schedule_crons();\n        add_action('podlove_cleanup_logging_table', [__CLASS__, 'cleanup_logging_table']);\n    }\n\n    public function uninstall()\n    {\n        LogTable::destroy();\n    }\n\n    public static function schedule_crons()\n    {\n        if (!wp_next_scheduled('podlove_cleanup_logging_table')) {\n            wp_schedule_event(time(), 'daily', 'podlove_cleanup_logging_table');\n        }\n    }\n\n    public static function cleanup_logging_table()\n    {\n        LogTable::cleanup();\n    }\n\n    public function was_activated($module_name)\n    {\n        LogTable::build();\n    }\n\n    public function register_database_logger()\n    {\n        global $wpdb;\n\n        $log = Log::get();\n        // write logs to database\n        $log->pushHandler(new WPDBHandler($wpdb, $log->get_log_level()));\n        // send critical logs via email\n        // $log->pushHandler( new WPMailHandler( get_option( 'admin_email' ), \"Podlove | Critical notice for \" . get_option( 'blogname' ), Logger::CRITICAL ) );\n    }\n\n    public function dashoard_template()\n    {\n        ?>\n<style type=\"text/css\">\n\n#podlove-log-wrapper {\n\tmax-height: 500px;\n\twidth: 100%;\n\toverflow-y: auto;\n\toverflow-x: inherit;\n}\n\n#podlove-log {\n\tfont-family: monospace;\n\tfont-size: 14px;\n\tline-height: 18px;\n\tmargin-top: 5px;\n}\n\n#podlove-log td {\n\tvertical-align: top;\n}\n\n.log-date {\n\twidth: 175px;\n\tpadding: 0px 4px;\n}\n\n#podlove-log-filter {\n\ttext-align: right;\n\twidth: 100%;\n}\n\n#podlove-log-filter .log-level {\n\tpadding: 0px 4px 2px 4px;\n}\n\n.log-level {\n\tdisplay: inline-block;\n\tmargin-left: 10px;\n}\n\n.log-level-200 {  } /* info */\n.log-level-300,\n.log-level-300 td:first-child { border-left: 2px solid #ffb900; background: #fff8e5; } /* warning */\n.log-level-400,\n.log-level-400 td:first-child { border-left: 2px solid #dc3232; background: #fbeaea; } /* error */\n.log-level-550 { background: #95002B; color: #FAD4AF; }\n.log-level-550 a { color: #F4E6AD; }\n\ncode.details {\n\tdisplay: inline-block;\n\tmargin: 0;\n\tpadding: 5px 15px;\n\tfont-size: smaller;\n\tline-height: 115%;\n\tcolor: #666;\n\tbackground: #F9F9F9;\n\tword-break: break-all;\n\tword-wrap: break-word;\n}\n\n#podlove-debug-log {\n\twidth: 80%;\n\tmax-width: 80%;\n}\n</style>\n\n<script type=\"text/javascript\">\n(function ($) {\n\nfunction filter_log() {\n\tvar filterWrapper = $(\"#podlove-log-filter\"),\n\t\tdebug    = filterWrapper.find(\".log-level.log-level-100 input[type=checkbox]:checked\").length,\n\t\tinfo    = filterWrapper.find(\".log-level.log-level-200 input[type=checkbox]:checked\").length,\n\t\twarning = filterWrapper.find(\".log-level.log-level-300 input[type=checkbox]:checked\").length,\n\t\terror   = filterWrapper.find(\".log-level.log-level-400 input[type=checkbox]:checked\").length,\n\t\tlog = $(\"#podlove-log\")\n\t;\n\n\tlog.find(\".log-entry.log-level-100\").toggle(!!debug);\n\tlog.find(\".log-entry.log-level-200\").toggle(!!info);\n\tlog.find(\".log-entry.log-level-300\").toggle(!!warning);\n\tlog.find(\".log-entry.log-level-400\").toggle(!!error);\n\n\t// always scroll to newest when filtering\n\t$(\"#podlove-log-wrapper\").scrollTop($(\"#podlove-log-wrapper\")[0].scrollHeight);\n}\n\n$(document).ready(function() {\n\t// scroll down\n\t$(\"#podlove-log\").on('click', '.log-details .toggle a', function(e) {\n\t\te.preventDefault();\n\t\t$(this).closest('.log-details').find('.details').toggle();\n\t});\n\t$(\"#podlove-log-filter input\").change(filter_log);\n\tfilter_log();\n});\n\n})(jQuery);\n</script>\n\n\t\t<?php\n        if ($timezone = get_option('timezone_string')) {\n            date_default_timezone_set($timezone);\n        } ?>\n\n\t\t<h3><?php echo __('Debug Logging', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\n\t\t<div id=\"podlove-debug-log\" class=\"card\">\n\n\t\t<div id=\"podlove-log-filter\">\n\t\t\t<div class=\"log-level log-level-100\">\n\t\t\t\t<label>\n\t\t\t\t\t<input type=\"checkbox\">\n\t\t\t\t\tdebug\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t\t<div class=\"log-level log-level-200\">\n\t\t\t\t<label>\n\t\t\t\t\t<input type=\"checkbox\">\n\t\t\t\t\tinfo\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t\t<div class=\"log-level log-level-300\">\n\t\t\t\t<label>\n\t\t\t\t\t<input type=\"checkbox\" checked>\n\t\t\t\t\twarning\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t\t<div class=\"log-level log-level-400\">\n\t\t\t\t<label>\n\t\t\t\t\t<input type=\"checkbox\" checked>\n\t\t\t\t\terror\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t</div>\n\n\n\t\t<div id=\"podlove-log-wrapper\">\n\t\t<table id=\"podlove-log\" cellspacing=\"0\" border=\"0\">\n\t\t\t<tbody>\n\t\t\t<?php foreach (LogTable::find_all_by_where('time > '.strtotime('-2 weeks')) as $log_entry) { ?>\n\t\t\t\t<tr class=\"log-entry log-level-<?php echo $log_entry->level; ?>\">\n\t\t\t\t\t<td class=\"log-date\">\n\t\t\t\t\t\t<?php echo date('Y-m-d H:i:s', $log_entry->time); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"log-content\">\n\t\t\t\t\t\t<span class=\"log-message\">\n\t\t\t\t\t\t\t<?php echo $log_entry->message; ?>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"log-extra\">\n\t\t\t\t\t\t\t<?php\n                            $data = json_decode($log_entry->context);\n\t\t\t    if (isset($data->media_file_id)) {\n\t\t\t        if ($media_file = Model\\MediaFile::find_by_id($data->media_file_id)) {\n\t\t\t            if ($episode = $media_file->episode()) {\n\t\t\t                if ($asset = $media_file->episode_asset()) {\n\t\t\t                    echo sprintf('<a href=\"%s\">%s/%s</a>', get_edit_post_link($episode->post_id), $episode->slug(), esc_html($asset->title));\n\t\t\t                }\n\t\t\t            }\n\t\t\t        }\n\t\t\t    }\n\t\t\t    if (isset($data->error)) {\n\t\t\t        echo sprintf(' \"%s\"', $data->error);\n\t\t\t    }\n\t\t\t    if (isset($data->episode_id)) {\n\t\t\t        if ($episode = Model\\Episode::find_by_id($data->episode_id)) {\n\t\t\t            echo sprintf(' <a href=\"%s\">%s</a>', get_edit_post_link($episode->post_id), get_the_title($episode->post_id));\n\t\t\t        }\n\t\t\t    }\n\t\t\t    if (isset($data->http_code)) {\n\t\t\t        echo ' HTTP Status: '.$data->http_code;\n\t\t\t    }\n\t\t\t    if (isset($data->mime_type, $data->expected_mime_type)) {\n\t\t\t        echo \" Expected: {$data->expected_mime_type}, but found: {$data->mime_type}\";\n\t\t\t    }\n\t\t\t    if (isset($data->type) && $data->type == 'twig') {\n\t\t\t        echo sprintf('in template \"%s\" line %d', print_r($data->template, true), $data->line);\n\t\t\t    }\n\n\t\t\t    $data = (array) $data;\n\t\t\t    $remove_keys = ['type', 'mime_type', 'expected_mime_type', 'error', 'episode_id'];\n\t\t\t    $extra = $data;\n\n\t\t\t    foreach ($remove_keys as $key) {\n\t\t\t        if (isset($extra[$key])) {\n\t\t\t            unset($extra[$key]);\n\t\t\t        }\n\t\t\t    }\n\n\t\t\t    if (count($extra) > 0) {\n\t\t\t        ?>\n\t\t\t\t\t\t\t\t<span class=\"log-details\">\n\t\t\t\t\t\t\t\t\t<span class=\"toggle\"><a href=\"#\"><?php echo __('toggle details', 'podlove-podcasting-plugin-for-wordpress'); ?></a></span>\n\t\t\t\t\t\t\t\t\t<code class=\"details\" style=\"display: none\"><pre><?php print_r($extra); ?></pre></code>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<?php\n\t\t\t    } elseif (!$data && !empty($log_entry->context)) {\n\t\t\t        ?>\n\t\t\t\t\t\t\t\t<span class=\"log-details\">\n\t\t\t\t\t\t\t\t\t<span class=\"toggle\"><a href=\"#\"><?php echo __('toggle details', 'podlove-podcasting-plugin-for-wordpress'); ?></a></span>\n\t\t\t\t\t\t\t\t\t<code class=\"details\" style=\"display: none\"><pre><?php echo str_replace(',\"', ','.\"\\n\".'\"', $log_entry->context); ?></pre></code>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<?php\n\t\t\t    } ?>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t<?php } ?>\n\t\t\t</tbody>\n\t\t</table>\n\t\t</div>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/logging/wpdbhandler.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Logging;\n\nuse PodlovePublisher_Vendor\\Monolog\\Handler\\AbstractProcessingHandler;\nuse PodlovePublisher_Vendor\\Monolog\\Logger;\n\nclass WPDBHandler extends AbstractProcessingHandler\n{\n    private $wpdb;\n\n    public function __construct($wpdb, $level = Logger::DEBUG, $bubble = true)\n    {\n        $this->wpdb = $wpdb;\n        parent::__construct($level, $bubble);\n    }\n\n    protected function write(array $record): void\n    {\n        $row = new LogTable();\n        $row->channel = $record['channel'];\n        $row->level = $record['level'];\n        $row->message = esc_sql($record['message']);\n        $row->context = wp_json_encode($record['context']);\n        $row->time = $record['datetime']->format('U');\n        $row->save();\n    }\n}\n"
  },
  {
    "path": "lib/modules/logging/wpmail_handler.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Logging;\n\nuse Podlove\\Model;\nuse PodlovePublisher_Vendor\\Monolog\\Handler\\MailHandler;\nuse PodlovePublisher_Vendor\\Monolog\\Logger;\n\n/**\n * WPMailHandler uses the wp_mail() function to send the emails.\n *\n * @author Christophe Coevoet <stof@notk.org>\n */\nclass WPMailHandler extends MailHandler\n{\n    protected $to;\n    protected $subject;\n    protected $headers = [\n        'Content-type: text/plain; charset=utf-8',\n    ];\n\n    /**\n     * @param array|string $to      The receiver of the mail\n     * @param string       $subject The subject of the mail\n     * @param int          $level   The minimum logging level at which this handler will be triggered\n     * @param bool         $bubble  Whether the messages that are handled can bubble up the stack or not\n     */\n    public function __construct($to, $subject, $level = Logger::ERROR, $bubble = true)\n    {\n        parent::__construct($level, $bubble);\n        $this->to = is_array($to) ? $to : [$to];\n        $this->subject = $subject;\n    }\n\n    protected function send($content, array $records)\n    {\n        $record = $records[0];\n\n        $content = wordwrap($content, 70);\n\n        if (isset($record['context']['episode_id'])) {\n            $episode = Model\\Episode::find_by_id($record['context']['episode_id']);\n            $content .= \"\\n\\n\".wp_specialchars_decode(get_edit_post_link($episode->post_id));\n        }\n\n        foreach ($this->to as $to) {\n            wp_mail($to, $this->subject, $content);\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/admin_bar_menu.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks;\n\nuse Podlove\\Model\\Podcast;\n\nclass AdminBarMenu\n{\n    public static function init()\n    {\n        add_action('admin_bar_menu', [__CLASS__, 'enhance_admin_bar'], 120);\n\n        add_action('wp_enqueue_scripts', [__CLASS__, 'maybe_print_styles']);\n        add_action('admin_enqueue_scripts', [__CLASS__, 'maybe_print_styles']);\n\n        add_action('wp_ajax_podlove-network-cover-style', [__CLASS__, 'print_styles']);\n    }\n\n    public static function maybe_print_styles()\n    {\n        if (is_admin_bar_showing()) {\n            wp_enqueue_style(\n                'podlove-network-cover-style',\n                admin_url('admin-ajax.php').'?action=podlove-network-cover-style',\n                [],\n                \\Podlove\\get_plugin_header('Version')\n            );\n        }\n    }\n\n    public static function print_styles()\n    {\n        header('Content-type: text/css');\n        echo \\Podlove\\Cache\\TemplateCache::get_instance()->cache_for('podlove_admin_menu_style_covers', function () {\n            return self::get_styles();\n        }, HOUR_IN_SECONDS);\n        exit;\n    }\n\n    public static function enhance_admin_bar($wp_admin_bar)\n    {\n        self::add_podcast_list($wp_admin_bar);\n        self::add_network_entries($wp_admin_bar);\n    }\n\n    private static function get_styles()\n    {\n        ob_start();\n\n        $podcast_ids = self::podcast_ids();\n        $podcast_ids = array_filter($podcast_ids, function ($id) {\n            return Podcast::get($id)->has_cover_art();\n        });\n\n        $blavatar_classes = implode(', ', array_map(function ($id) {\n            return \"#wp-admin-bar-blog-{$id} .blavatar\";\n        }, $podcast_ids));\n\n        $blavatar_before_classes = implode(', ', array_map(function ($id) {\n            return \"#wpadminbar .quicklinks li#wp-admin-bar-blog-{$id} .blavatar:before\";\n        }, $podcast_ids));\n\n        $cover_styles = implode(\"\\n\", array_map(function ($id) {\n            return \"#wp-admin-bar-blog-{$id} .blavatar { background-image: url(\".Podcast::get($id)->cover_art()->setWidth(40)->url().'); }';\n        }, $podcast_ids)); ?>\n<?php echo $cover_styles; ?>\n<?php echo $blavatar_classes; ?> {\n\tbackground-size: 100% 100%;\n\tmargin-right: 5px;\n\twidth: 20px;\n\theight: 20px;\n\tposition: relative;\n\ttop: 4px;\n\tleft: -3px;\n}\n\n<?php echo $blavatar_before_classes; ?> {\n\tcontent: none;\n}\n\t\t<?php\n\n        $html = ob_get_contents();\n        ob_end_clean();\n\n        return $html;\n    }\n\n    private static function add_network_entries($wp_admin_bar)\n    {\n        if (!self::is_publisher_plugin_active_for_network()) {\n            return;\n        }\n\n        // add network dashboard\n        $wp_admin_bar->add_node([\n            'id' => self::podcast_toolbar_id('network', 'dashboard'),\n            'title' => __('Podlove Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent' => 'network-admin',\n            'href' => network_admin_url('admin.php?page=podlove_network_settings_handle'),\n        ]);\n\n        // add network templates\n        $wp_admin_bar->add_node([\n            'id' => self::podcast_toolbar_id('network', 'templates'),\n            'title' => __('Podlove Templates', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent' => 'network-admin',\n            'href' => network_admin_url('admin.php?page=podlove_templates_settings_handle'),\n        ]);\n    }\n\n    private static function is_publisher_plugin_active_for_network()\n    {\n        if (!function_exists('is_plugin_active_for_network')) {\n            require_once ABSPATH.'/wp-admin/includes/plugin.php';\n        }\n\n        return is_plugin_active_for_network(basename(\\Podlove\\PLUGIN_DIR).'/'.\\Podlove\\PLUGIN_FILE_NAME);\n    }\n\n    private static function add_podcast_list($wp_admin_bar)\n    {\n        foreach (self::podcast_ids() as $podcast_id) {\n            self::add_podcast($podcast_id, $wp_admin_bar);\n        }\n    }\n\n    private static function add_podcast($podcast_id, $wp_admin_bar)\n    {\n        switch_to_blog($podcast_id);\n\n        // Register Dashboard & Episodes per Podcast\n        $wp_admin_bar->add_node([\n            'id' => self::podcast_toolbar_id($podcast_id, 'dashboard'),\n            'title' => __('Podlove Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent' => 'blog-'.$podcast_id,\n            'href' => get_admin_url($podcast_id, 'admin.php?page=podlove_settings_handle'),\n        ]);\n\n        $wp_admin_bar->add_node([\n            'id' => self::podcast_toolbar_id($podcast_id, 'episodes'),\n            'title' => __('Podlove Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent' => 'blog-'.$podcast_id,\n            'href' => get_admin_url($podcast_id, 'edit.php?post_type=podcast'),\n        ]);\n\n        do_action('podlove_network_admin_bar_podcast', $wp_admin_bar, $podcast_id);\n\n        restore_current_blog();\n    }\n\n    private static function podcast_ids()\n    {\n        return Model\\PodcastList::with_network_scope(function () {\n            return Model\\Network::podcast_blog_ids();\n        });\n    }\n\n    private static function podcast_toolbar_id($podcast_id, $suffix = '')\n    {\n        return 'podlove_toolbar_'.$podcast_id.($suffix ? '_'.$suffix : '');\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/css/admin.css",
    "content": "table.lists td.column-logo img,\ntable.podcasts td.column-logo img {\n\twidth: 70px;\n}\n\n#toplevel_page_podlove_network_settings_handle_podcast_overview .inside {\n\tpadding: 0;\n\tmargin-top: 0px;\n}\n\n#toplevel_page_podlove_network_settings_handle_podcast_overview .tablenav,\n#toplevel_page_podlove_network_settings_handle_podcast_overview .wp-list-table tfoot {\n\tdisplay: none;\n}\n\n#toplevel_page_podlove_network_settings_handle_podcast_overview .wp-list-table {\n\tborder-width: 0px;\n}\n\n#toplevel_page_podlove_network_settings_handle_list_overview .inside {\n\tpadding: 0;\n\tmargin-top: 0px;\n}\n\n#toplevel_page_podlove_network_settings_handle_list_overview .tablenav,\n#toplevel_page_podlove_network_settings_handle_list_overview .wp-list-table tfoot {\n\tdisplay: none;\n}\n\n#toplevel_page_podlove_network_settings_handle_list_overview .wp-list-table {\n\tborder-width: 0px;\n}\n"
  },
  {
    "path": "lib/modules/networks/model/network.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Model;\n\nuse Podlove\\Model\\Podcast;\n\nclass Network\n{\n    public static function blog_ids()\n    {\n        global $wpdb;\n\n        if ($wpdb->blogs) {\n            $blogs = $wpdb->get_col(\"SELECT blog_id FROM {$wpdb->blogs} WHERE NOT archived\");\n        } else {\n            $blogs = [];\n        }\n\n        return $blogs;\n    }\n\n    /**\n     * Fetch all blog IDs for Publisher blogs.\n     */\n    public static function podcast_blog_ids()\n    {\n        return array_filter(Network::blog_ids(), function ($blog_id) {\n            return \\Podlove\\with_blog_scope($blog_id, function () {\n                return is_plugin_active(plugin_basename(\\Podlove\\PLUGIN_FILE));\n            });\n        });\n    }\n\n    /**\n     * Fetch all podcasts for Publisher blogs, ordered.\n     *\n     * @param mixed $sortby\n     * @param mixed $sort\n     */\n    public static function podcasts($sortby = 'title', $sort = 'ASC')\n    {\n        $podcast_blog_ids = Network::podcast_blog_ids();\n\n        if (empty($podcast_blog_ids)) {\n            return [];\n        }\n\n        foreach ($podcast_blog_ids as $blog_id) {\n            $podcasts[$blog_id] = Podcast::get($blog_id);\n        }\n\n        // if it doesn't have a title, it's not a podcast\n        $podcasts = array_filter($podcasts, function ($podcast) {\n            return strlen(trim($podcast->title)) > 0;\n        });\n\n        uasort($podcasts, function ($a, $b) use ($sortby) {\n            return strnatcmp($a->{$sortby}, $b->{$sortby});\n        });\n\n        if ($sort == 'DESC') {\n            krsort($podcasts);\n        }\n\n        return $podcasts;\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/model/podcast_list.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Model;\n\nuse Podlove\\Model\\Base;\nuse Podlove\\Model\\Podcast;\n\n/**\n * Lists are a model that can be used to organize Podcasts (e.g. networks).\n */\nclass PodcastList extends Base\n{\n    use \\Podlove\\Model\\NetworkTrait;\n\n    /**\n     * Fetch all Pocasts in the current list.\n     */\n    public function podcasts()\n    {\n        $podcasts = json_decode($this->podcasts);\n\n        $podcast_objects = [];\n        foreach ($podcasts as $podcast) {\n            switch ($podcast->type) {\n                default: case 'wplist':\n                    $podcast_objects[] = Podcast::get($podcast->podcast);\n\n                    break;\n            }\n        }\n\n        return $podcast_objects;\n    }\n\n    /**\n     * Fetch episodes for the list.\n     *\n     * @param mixed $number_of_episodes\n     * @param mixed $orderby\n     * @param mixed $order\n     */\n    public function latest_episodes($number_of_episodes = 10, $orderby = 'post_date', $order = 'DESC')\n    {\n        global $wpdb;\n\n        $podcasts = $this->podcasts();\n\n        // sanitize order\n        $order = $order == 'DESC' ? 'DESC' : 'ASC';\n\n        // sanitize orderby\n        $valid_orderby = ['post_date', 'post_title', 'ID', 'comment_count'];\n        $orderby = in_array($orderby, $valid_orderby) ? $orderby : 'post_date';\n\n        // Generate mySQL Query\n        $subqueries = [];\n        foreach ($podcasts as $podcast_key => $podcast) {\n            $subqueries[] = $podcast->with_blog_scope(function () use ($podcast) {\n                global $wpdb;\n\n                $query = '(SELECT p.ID, p.post_title, p.post_date, b.blog_id FROM '.$wpdb->posts.' p, '.$wpdb->blogs.\" b\\n\";\n                $query .= \"WHERE p.post_type = 'podcast'\";\n                $query .= \"AND p.post_status = 'publish'\";\n                $query .= 'AND b.blog_id = '.$podcast->get_blog_id().')';\n\n                return $query;\n            });\n        }\n\n        $query = implode(\"UNION\\n\", $subqueries).\" ORDER BY {$orderby} {$order} LIMIT 0, \".(int) $number_of_episodes;\n\n        $recent_posts = $wpdb->get_results($query);\n\n        $episodes = [];\n        foreach ($recent_posts as $post) {\n            switch_to_blog($post->blog_id);\n            if ($episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post->ID)) {\n                $episodes[] = new \\Podlove\\Template\\Episode($episode);\n            }\n            restore_current_blog();\n        }\n\n        return $episodes;\n    }\n}\n\nPodcastList::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nPodcastList::property('title', 'VARCHAR(255)');\nPodcastList::property('slug', 'VARCHAR(255)');\nPodcastList::property('subtitle', 'TEXT');\nPodcastList::property('description', 'TEXT');\nPodcastList::property('url', 'TEXT');\nPodcastList::property('logo', 'TEXT');\nPodcastList::property('podcasts', 'TEXT');\n"
  },
  {
    "path": "lib/modules/networks/networks.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks;\n\nuse Podlove\\Model\\Template;\nuse Podlove\\Modules\\Networks\\Model\\PodcastList;\n\nclass Networks extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Podcast Network';\n    protected $module_description = 'Support for Podcast Networks using <a href=\"http://codex.wordpress.org/Create_A_Network\">WordPress Multisite</a> environments.';\n    protected $module_group = 'system';\n\n    public static function is_core()\n    {\n        return true;\n    }\n\n    public function load()\n    {\n        // skip module outsite multisite environments\n        if (!function_exists('is_plugin_active_for_network')) {\n            require_once ABSPATH.'/wp-admin/includes/plugin.php';\n        }\n\n        if (is_multisite()) {\n            // Actions after activation\n            add_action('podlove_module_was_activated_networks', [$this, 'was_activated']);\n        }\n\n        // filter allows force-enabling network module\n        // @see https://github.com/podlove/podlove-publisher/issues/995\n        $active = is_plugin_active_for_network(basename(\\Podlove\\PLUGIN_DIR).'/'.\\Podlove\\PLUGIN_FILE_NAME);\n        if (!apply_filters('podlove_network_module_activate', $active)) {\n            return;\n        }\n\n        // Adding Network Admin Menu\n        add_action('network_admin_menu', [$this, 'create_network_menu']);\n\n        // Twig template filter\n        add_filter('podlove_templates_global_context', [$this, 'twig_template_filter']);\n\n        add_filter('podlove_system_report_fields', [$this, 'add_system_report_validations']);\n\n        // Styles\n        add_action('admin_print_styles', [$this, 'scripts_and_styles']);\n\n        AdminBarMenu::init();\n    }\n\n    public function add_system_report_validations($fields)\n    {\n        $fields['multisite'] = [\n            'callback' => function () {\n                if (self::is_active_in_main_blog()) {\n                    return 'ok';\n                }\n\n                return [\n                    'message' => __('Podlove Publisher is not activated in main blog!', 'podlove-podcasting-plugin-for-wordpress'),\n                    'notice' => __('If you want to use Podcast network functionality you have to activate the Podlove Plugin in your main WordPress blog to work properly', 'podlove-podcasting-plugin-for-wordpress'),\n                ];\n            },\n        ];\n\n        return $fields;\n    }\n\n    public function twig_template_filter($context)\n    {\n        return array_merge(\n            $context,\n            ['network' => new \\Podlove\\Modules\\Networks\\Template\\Network()]\n        );\n    }\n\n    // Was activated\n    public function was_activated($module_name = 'networks')\n    {\n        PodcastList::with_network_scope(function () {\n            PodcastList::build();\n        });\n\n        Template::with_network_scope(function () {\n            Template::build();\n        });\n    }\n\n    public function uninstall()\n    {\n        PodcastList::with_network_scope(function () {\n            PodcastList::destroy();\n        });\n    }\n\n    // Register Network Admin Menu\n    public function create_network_menu()\n    {\n        // create new top-level menu\n        $hook = add_menu_page(\n            // $page_title\n            'Podlove Plugin Settings',\n            // $menu_title\n            'Podlove',\n            // $capability\n            'administrator',\n            // $menu_slug\n            \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE,\n            // $function\n            function () { // see \\Podlove\\Settings\\Dashboard\n            },\n            // $icon_url\n            'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjQsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCIgWw0KCTwhRU5USVRZIG5zX2V4dGVuZCAiaHR0cDovL25zLmFkb2JlLmNvbS9FeHRlbnNpYmlsaXR5LzEuMC8iPg0KCTwhRU5USVRZIG5zX2FpICJodHRwOi8vbnMuYWRvYmUuY29tL0Fkb2JlSWxsdXN0cmF0b3IvMTAuMC8iPg0KCTwhRU5USVRZIG5zX2dyYXBocyAiaHR0cDovL25zLmFkb2JlLmNvbS9HcmFwaHMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfdmFycyAiaHR0cDovL25zLmFkb2JlLmNvbS9WYXJpYWJsZXMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfaW1yZXAgImh0dHA6Ly9ucy5hZG9iZS5jb20vSW1hZ2VSZXBsYWNlbWVudC8xLjAvIj4NCgk8IUVOVElUWSBuc19zZncgImh0dHA6Ly9ucy5hZG9iZS5jb20vU2F2ZUZvcldlYi8xLjAvIj4NCgk8IUVOVElUWSBuc19jdXN0b20gImh0dHA6Ly9ucy5hZG9iZS5jb20vR2VuZXJpY0N1c3RvbU5hbWVzcGFjZS8xLjAvIj4NCgk8IUVOVElUWSBuc19hZG9iZV94cGF0aCAiaHR0cDovL25zLmFkb2JlLmNvbS9YUGF0aC8xLjAvIj4NCl0+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zOng9IiZuc19leHRlbmQ7IiB4bWxuczppPSImbnNfYWk7IiB4bWxuczpncmFwaD0iJm5zX2dyYXBoczsiDQoJIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiDQoJIHZpZXdCb3g9IjAgMCAxMjggMTI4IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxtZXRhZGF0YT4NCgk8c2Z3ICB4bWxucz0iJm5zX3NmdzsiPg0KCQk8c2xpY2VzPjwvc2xpY2VzPg0KCQk8c2xpY2VTb3VyY2VCb3VuZHMgIGhlaWdodD0iMTI3Ljk4MyIgd2lkdGg9IjcyLjQyNCIgYm90dG9tTGVmdE9yaWdpbj0idHJ1ZSIgeD0iMjcuMzk2IiB5PSIwLjUwNSI+PC9zbGljZVNvdXJjZUJvdW5kcz4NCgk8L3Nmdz4NCjwvbWV0YWRhdGE+DQo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNOTIuMjczLDEyNy45OTVIMzUuOTQzYy00LjQ0NCwwLTguMDQ3LTMuNTgxLTguMDQ3LTcuOTk5VjguMDExYzAtNC40MTcsMy42MDMtNy45OTksOC4wNDctNy45OTloNTYuMzMxDQoJYzQuNDQzLDAsOC4wNDcsMy41ODIsOC4wNDcsNy45OTl2MTExLjk4NUMxMDAuMzIsMTI0LjQxNCw5Ni43MTgsMTI3Ljk5NSw5Mi4yNzMsMTI3Ljk5NXogTTYzLjYwNSwxMTEuOTk2DQoJYzEzLjMzMywwLDI0LjE0MS0xMC43NDMsMjQuMTQxLTIzLjk5N2MwLTEzLjI1MS0xMC44MDktMjMuOTk1LTI0LjE0MS0yMy45OTVjLTEzLjMzMywwLTI0LjE0MSwxMC43NDQtMjQuMTQxLDIzLjk5NQ0KCUMzOS40NjQsMTAxLjI1Myw1MC4yNzMsMTExLjk5Niw2My42MDUsMTExLjk5NnogTTkyLjI3Myw4LjAxMUgzNS45NDN2NDcuOTkzaDU2LjMzMVY4LjAxMUw5Mi4yNzMsOC4wMTF6IE02My42MDUsNzkuMjQ2DQoJYzQuODY0LDAsOC44MDYsMy45Miw4LjgwNiw4Ljc1M2MwLDQuODM2LTMuOTQsOC43NTUtOC44MDYsOC43NTVjLTQuODY0LDAtOC44MDctMy45MTktOC44MDctOC43NTUNCglDNTQuNzk5LDgzLjE2Niw1OC43NDIsNzkuMjQ2LDYzLjYwNSw3OS4yNDZ6Ii8+DQo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNNjMuOTkyLDIyLjk3MmM1LjAzMy0xMS4yNSwyMC4yOTktOS4wOTgsMjAuMzk4LDQuNTM0YzAuMDU3LDcuODA5LTIwLjM2OSwyMS44NzEtMjAuMzY5LDIxLjg3MQ0KCXMtMjAuNDctMTMuOTI5LTIwLjQxMy0yMS43ODlDNDMuNzA4LDEzLjk4OCw1OC43MTIsMTEuMjUzLDYzLjk5MiwyMi45NzJ6Ii8+DQo8L3N2Zz4NCg=='\n            // $position\n        );\n\n        new \\Podlove\\Modules\\Networks\\Settings\\Dashboard(\\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Modules\\Networks\\Settings\\PodcastLists(\\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Modules\\Networks\\Settings\\Templates(\\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE);\n\n        do_action('podlove_register_settings_pages', \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE);\n    }\n\n    public function scripts_and_styles()\n    {\n        wp_register_style(\n            'podlove_network_admin_style',\n            \\Podlove\\PLUGIN_URL.'/lib/modules/networks/css/admin.css',\n            false,\n            \\Podlove\\get_plugin_header('Version')\n        );\n        wp_enqueue_style('podlove_network_admin_style');\n    }\n\n    private static function is_active_in_main_blog()\n    {\n        global $current_site;\n\n        return \\Podlove\\with_blog_scope($current_site->blog_id, function () {\n            return is_plugin_active(plugin_basename(\\Podlove\\PLUGIN_FILE));\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/podcast_list_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks;\n\nuse Podlove\\Modules\\Networks\\Model\\PodcastList;\n\nclass PodcastList_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'list',   // singular name of the listed records\n            'plural' => 'lists',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_title($list)\n    {\n        $actions = [\n            'edit' => Settings\\PodcastLists::get_action_link($list, __('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => Settings\\PodcastLists::get_action_link($list, __('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            Settings\\PodcastLists::get_action_link($list, $list->title),\n            $this->row_actions($actions)\n        ).'<input type=\"hidden\" class=\"list_id\" value=\"'.$list->id.'\">';\n    }\n\n    public function column_logo($list)\n    {\n        if ($list->logo == '') {\n            return;\n        }\n\n        return \"<img src='\".$list->logo.\"' title='\".$list->title.\"' alt='\".$list->title.\"' />\";\n    }\n\n    public function column_url($list)\n    {\n        return \"<a href='\".$list->url.\"'>\".$list->url.'</a>';\n    }\n\n    public function column_podcasts($list)\n    {\n        return implode(', ', array_map(function ($podcast) {\n            return $this->podcast_admin_link($podcast);\n        }, $list->podcasts()));\n    }\n\n    public function podcast_admin_link($podcast)\n    {\n        return sprintf(\n            '<a href=\"%s\">%s</a>',\n            get_admin_url($podcast->blog_id),\n            $podcast->title\n        );\n    }\n\n    public function get_columns()\n    {\n        return [\n            'logo' => __('Logo', 'podlove-podcasting-plugin-for-wordpress'),\n            'title' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n            'url' => __('URL', 'podlove-podcasting-plugin-for-wordpress'),\n            'podcasts' => __('Podcasts', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    /**\n     * @override\n     */\n    public function display()\n    {\n        parent::display(); ?>\n\t\t<style type=\"text/css\">\n\t\t/* avoid mouseover jumping */\n\t\t#permanentcontributor { width: 160px; }\n\t\t</style>\n\t\t<?php\n    }\n\n    public function prepare_items()\n    {\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = false;\n        $this->_column_headers = [$columns, $hidden, $sortable];\n        PodcastList::activate_network_scope();\n        $items = \\Podlove\\Modules\\Networks\\Model\\PodcastList::all();\n        PodcastList::deactivate_network_scope();\n\n        uasort($items, function ($a, $b) {\n            return strnatcmp($a->title, $b->title);\n        });\n\n        $this->items = $items;\n    }\n\n    public function no_items()\n    {\n        ?>\n\t\t<div style=\"margin: 20px 10px 10px 5px\">\n\t \t\t<span class=\"add-new-h2\" style=\"background: transparent\">\n\t\t\t<?php _e('No items found.'); ?>\n\t\t\t</span>\n\t\t\t<a href=\"?page=podlove_settings_list_handle&action=new\" class=\"add-new-h2\">\n\t \t\t<?php _e('Add New'); ?>\n\t \t\t</a>\n\t \t</div>\n\t \t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/podcast_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks;\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Networks\\Model\\Network;\n\nclass Podcast_List_Table extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        global $status, $page;\n\n        // Set parent defaults\n        parent::__construct([\n            'singular' => 'podcast',   // singular name of the listed records\n            'plural' => 'podcasts',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function no_items_content()\n    {\n        ?>\n\t\t<span class=\"add-new-h2\" style=\"background: transparent\">\n\t\t\t<?php _e('No podcasts exist yet.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</span>\n\t\t<?php\n    }\n\n    public function column_title($podcast)\n    {\n        return $podcast->with_blog_scope(function () use ($podcast) {\n            if ($podcast->title) {\n                return \"<a href='\".admin_url().\"admin.php?page=podlove_settings_handle'>\".$podcast->title.'</a> <br />'.$podcast->subtitle;\n            }\n\n            return sprintf(__('No podcast title in blog %s.', 'podlove-podcasting-plugin-for-wordpress'), '<a href=\"'.admin_url().'\">'.get_bloginfo('name').'</a>');\n        });\n    }\n\n    public function column_logo($podcast)\n    {\n        if (!trim($podcast->cover_art()->url())) {\n            return;\n        }\n\n        return $podcast->cover_art()->setWidth(70)->image(['alt' => $podcast->title]);\n    }\n\n    public function column_episodes($podcast)\n    {\n        return $podcast->with_blog_scope(function () {\n            return count(Episode::find_all_by_time());\n        });\n    }\n\n    public function column_downloads($podcast)\n    {\n        return $podcast->with_blog_scope(function () {\n            $total = TemplateCache::get_instance()\n                ->cache_for('podlove_downloads_total', '\\Podlove\\Model\\DownloadIntentClean::total_downloads', 5 * MINUTE_IN_SECONDS)\n            ;\n\n            return is_numeric($total) ? number_format_i18n($total) : __('no data', 'podlove-podcasting-plugin-for-wordpress');\n        });\n    }\n\n    public function column_latest_episode($podcast)\n    {\n        return $podcast->with_blog_scope(function () {\n            if ($latest_episode = Episode::latest()) {\n                $latest_episode_blog_post = get_post($latest_episode->post_id);\n\n                return \"<a title='Published on \".date('Y-m-d h:i:s', strtotime($latest_episode_blog_post->post_date)).\"' href='\".admin_url().'post.php?post='.$latest_episode->post_id.\"&action=edit'>\".$latest_episode_blog_post->post_title.'</a>'\n                     .'<br />'.\\Podlove\\relative_time_steps(strtotime($latest_episode_blog_post->post_date));\n            }\n\n            return '—';\n        });\n    }\n\n    public function get_columns()\n    {\n        return [\n            'logo' => __('Logo', 'podlove-podcasting-plugin-for-wordpress'),\n            'title' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n            'episodes' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'downloads' => __('Downloads', 'podlove-podcasting-plugin-for-wordpress'),\n            'latest_episode' => __('Latest Episode', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function search_form()\n    {\n        ?>\n\t\t<form method=\"post\">\n\t\t  <?php $this->search_box('search', 'search_id'); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    /**\n     * @override\n     */\n    public function display()\n    {\n        parent::display(); ?>\n\t\t<style type=\"text/css\">\n\t\t/* avoid mouseover jumping */\n\t\t#permanentcontributor { width: 160px; }\n\t\t</style>\n\t\t<?php\n    }\n\n    public function prepare_items()\n    {\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = false;\n        $this->_column_headers = [$columns, $hidden, $sortable];\n        $items = Network::podcasts();\n\n        uasort($items, function ($a, $b) {\n            return strnatcmp($a->title, $b->title);\n        });\n\n        $this->items = $items;\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/settings/dashboard.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Settings;\n\nclass Dashboard\n{\n    public static $pagehook;\n\n    public function __construct()\n    {\n        // use \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE to replace\n        // default first item name\n        Dashboard::$pagehook = add_submenu_page(\n            // $parent_slug\n            \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE,\n            // $page_title\n            __('Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE,\n            // $function\n            [$this, 'settings_page']\n        );\n\n        add_action(Dashboard::$pagehook, function () {\n            wp_enqueue_script('postbox');\n            add_screen_option('layout_columns', [\n                'max' => 2, 'default' => 2,\n            ]);\n\n            wp_register_script(\n                'cornify-js',\n                \\Podlove\\PLUGIN_URL.'/js/admin/cornify.js'\n            );\n            wp_enqueue_script('cornify-js');\n        });\n    }\n\n    public static function settings_page()\n    {\n        add_meta_box(Dashboard::$pagehook.'_right_now', __('At a glance', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Modules\\Networks\\Settings\\Dashboard::right_now', Dashboard::$pagehook, 'normal');\n        add_meta_box(Dashboard::$pagehook.'_about', __('About', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Dashboard\\About::content', Dashboard::$pagehook, 'side');\n        add_meta_box(Dashboard::$pagehook.'_podcast_overview', __('Podcasts', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Modules\\Networks\\Settings\\Dashboard::podcast_overview', Dashboard::$pagehook, 'normal');\n        add_meta_box(Dashboard::$pagehook.'_list_overview', __('Lists', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Modules\\Networks\\Settings\\Dashboard::list_overview', Dashboard::$pagehook, 'normal');\n\n        do_action('podlove_network_dashboard_meta_boxes'); ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Podlove Network Dashboard', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t\t\t<div id=\"poststuff\" class=\"metabox-holder has-right-sidebar\">\n\t\t\t\t\n\t\t\t\t<!-- sidebar -->\n\t\t\t\t<div id=\"side-info-column\" class=\"inner-sidebar\">\n\t\t\t\t\t<?php do_action('podlove_settings_before_sidebar_boxes'); ?>\n\t\t\t\t\t<?php do_meta_boxes(Dashboard::$pagehook, 'side', null); ?>\n\t\t\t\t\t<?php do_action('podlove_settings_after_sidebar_boxes'); ?>\n\t\t\t\t</div>\n\n\t\t\t\t<!-- main -->\n\t\t\t\t<div id=\"post-body\" class=\"has-sidebar\">\n\t\t\t\t\t<div id=\"post-body-content\" class=\"has-sidebar-content\">\n\t\t\t\t\t\t<?php do_action('podlove_settings_before_main_boxes'); ?>\n\t\t\t\t\t\t<?php do_meta_boxes(Dashboard::$pagehook, 'normal', null); ?>\n\t\t\t\t\t\t<?php do_meta_boxes(Dashboard::$pagehook, 'additional', null); ?>\n\t\t\t\t\t\t<?php do_action('podlove_settings_after_main_boxes'); ?>\t\t\t\t\t\t\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<br class=\"clear\"/>\n\n\t\t\t</div>\n\n\t\t\t<!-- Stuff for opening / closing metaboxes -->\n\t\t\t<script type=\"text/javascript\">\n\t\t\tjQuery( document ).ready( function( $ ){\n\t\t\t\t// close postboxes that should be closed\n\t\t\t\t$( '.if-js-closed' ).removeClass( 'if-js-closed' ).addClass( 'closed' );\n\t\t\t\t// postboxes setup\n\t\t\t\tpostboxes.add_postbox_toggles( '<?php echo \\Podlove\\Podcast_Post_Type::NETWORK_SETTINGS_PAGE_HANDLE; ?>' );\n\t\t\t} );\n\t\t\t</script>\n\n\t\t\t<form style='display: none' method='get' action=''>\n\t\t\t\t<?php\n                wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false);\n        wp_nonce_field('meta-box-order', 'meta-box-order-nonce', false); ?>\n\t\t\t</form>\n\n\t\t</div>\n\t\t<?php\n    }\n\n    public static function right_now()\n    {\n        $podcasts = \\Podlove\\Modules\\Networks\\Model\\Network::podcast_blog_ids();\n        $number_of_podcasts = count($podcasts);\n\n        if (!$number_of_podcasts) {\n            echo __('No podcasts exist yet.', 'podlove-podcasting-plugin-for-wordpress');\n\n            return;\n        }\n\n        $episodes_total = 0;\n        $episodes_total_per_status = [\n            'publish' => 0,\n            'private' => 0,\n            'future' => 0,\n            'draft' => 0,\n        ];\n        $episodes_total_length = 0;\n        $episode_total_average_length = 0;\n        $media_file_total_average_size = 0;\n        $media_file_total_size = 0;\n\n        foreach ($podcasts as $podcast) {\n            switch_to_blog($podcast);\n            $statistics = \\Podlove\\Settings\\Dashboard\\Statistics::prepare_statistics();\n\n            $episodes_total += $statistics['total_number_of_episodes'];\n\n            array_walk($statistics['episodes'], function ($posts, $type) use (&$episodes_total_per_status) {\n                switch ($type) {\n                    case 'publish':\n                        $episodes_total_per_status['publish'] += $posts;\n\n                        break;\n                    case 'publish':\n                        $episodes_total_per_status['private'] += $posts;\n\n                        break;\n                    case 'future':\n                        $episodes_total_per_status['future'] += $posts;\n\n                        break;\n                    case 'draft':\n                        $episodes_total_per_status['draft'] += $posts;\n\n                        break;\n                }\n            });\n\n            $episodes_total_length += $statistics['total_episode_length'];\n            $media_file_total_size += $statistics['total_media_file_size'];\n            restore_current_blog();\n        }\n\n        // Devide stats by number of Podcasts\n        $episode_total_average_length = $episodes_total_length / $episodes_total;\n        $media_file_total_average_size = $media_file_total_size / $episodes_total; ?>\n\t\t<div class=\"podlove-dashboard-statistics-wrapper\">\n\t\t\t<h4>Episodes</h4>\n\t\t\t<table cellspacing=\"0\" cellpadding=\"0\" class=\"podlove-dashboard-statistics\">\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo $episodes_total_per_status['publish']; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<span style=\"color: #2c6e36;\"><?php echo __('Published', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo $episodes_total_per_status['private']; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<span style=\"color: #b43f56;\"><?php echo __('Private', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo $episodes_total_per_status['future']; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<span style=\"color: #a8a8a8;\"><?php echo __('To be published', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo $episodes_total_per_status['draft']; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<span style=\"color: #c0844c;\"><?php echo __('Drafts', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column podlove-dashboard-total-number\">\n\t\t\t\t\t\t<?php echo $episodes_total; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"podlove-dashboard-total-number\">\n\t\t\t\t\t\t<?php echo __('Total', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t</table>\n\t\t</div>\n\t\t<div class=\"podlove-dashboard-statistics-wrapper\">\n\t\t\t<h4><?php echo __('Statistics', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t\t<table cellspacing=\"0\" cellpadding=\"0\" class=\"podlove-dashboard-statistics\">\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo gmdate('H:i:s', $episode_total_average_length); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo __('is the average length of an episode', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php\n                            $days = round($episodes_total_length / 3600 / 24, 1);\n        echo sprintf(_n('%s day', '%s days', $days, 'podlove-podcasting-plugin-for-wordpress'), $days); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo __('is the total playback time of all episodes', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo \\Podlove\\format_bytes($media_file_total_average_size, 1); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo __('is the average media file size', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo \\Podlove\\format_bytes($media_file_total_size, 1); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo __('is the total media file size', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t\t\t<?php echo sprintf(_n('%s podcast', '%s podcasts', $number_of_podcasts, 'podlove-podcasting-plugin-for-wordpress'), $number_of_podcasts); ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo __('exist in your WordPress installation', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<?php do_action('podlove_dashboard_statistics_network'); ?>\n\t\t\t</table>\n\t\t</div>\n\t\t<p>\n\t\t\t<?php echo sprintf(__('You are using %s', 'podlove-podcasting-plugin-for-wordpress'), '<strong>Podlove Publisher '.\\Podlove\\get_plugin_header('Version').'</strong>'); ?>.\n\t\t</p>\n\t\t<?php\n    }\n\n    public static function podcast_overview()\n    {\n        switch_to_blog(1);\n        $table = new \\Podlove\\Modules\\Networks\\Podcast_List_Table();\n        $table->prepare_items();\n        $table->display();\n        restore_current_blog();\n    }\n\n    public static function list_overview()\n    {\n        switch_to_blog(1);\n        $table = new \\Podlove\\Modules\\Networks\\PodcastList_List_Table();\n        $table->prepare_items();\n        $table->display();\n        restore_current_blog();\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/settings/podcast_lists.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Settings;\n\nuse Podlove\\Modules\\Networks\\Model\\Network;\nuse Podlove\\Modules\\Networks\\Model\\PodcastList;\n\nclass PodcastLists\n{\n    public const MENU_SLUG = 'podlove_settings_list_handle';\n    public static $pagehook;\n    private static $nonce = 'update_podcast_list';\n\n    public function __construct($handle)\n    {\n        PodcastLists::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            'Lists',\n            // $menu_title\n            'Lists',\n            // $capability\n            'administrator',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['list']) && !isset($_REQUEST['podlove_list'])) {\n            return;\n        }\n\n        if (!isset($_REQUEST['_podlove_nonce']) || !wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        }\n    }\n\n    public static function get_action_link($list, $title, $action = 'edit', $class = 'link')\n    {\n        return sprintf(\n            '<a href=\"?page=%s&amp;action=%s&amp;list=%s&amp;_podlove_nonce=%s\" class=\"%s\">'.$title.'</a>',\n            self::MENU_SLUG,\n            $action,\n            $list->id,\n            wp_create_nonce(self::$nonce),\n            $class\n        );\n    }\n\n    public function page()\n    {\n        if (isset($_GET['action']) and $_GET['action'] == 'confirm_delete' and isset($_REQUEST['list'])) {\n            PodcastList::activate_network_scope();\n            $list = PodcastList::find_by_id($_REQUEST['list']);\n            PodcastList::deactivate_network_scope(); ?>\n\t\t\t<div class=\"updated\">\n\t\t\t\t<p>\n\t\t\t\t\t<strong>\n\t\t\t\t\t\t<?php echo sprintf(__('You selected to delete the list \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'), $list->title); ?>\n\t\t\t\t\t</strong>\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t<?php echo self::get_action_link($list, __('Delete list permanently', 'podlove-podcasting-plugin-for-wordpress'), 'delete', 'button'); ?>\n\t\t\t\t\t<?php echo self::get_action_link($list, __('Don\\'t change anything', 'podlove-podcasting-plugin-for-wordpress'), 'keep', 'button-primary'); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        } ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Lists', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=new\" class=\"add-new-h2\"><?php echo __('Add New', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\t\t\t<?php\n                if (isset($_GET['action'])) {\n                    switch ($_GET['action']) {\n                        case 'new':   $this->new_template();\n\n                            break;\n                        case 'edit':  $this->edit_template();\n\n                            break;\n\n                        default:      $this->view_template();\n\n                            break;\n                    }\n                } else {\n                    $this->view_template();\n                } ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    /**\n     * Process form: save/update a list.\n     */\n    private function save()\n    {\n        if (!isset($_REQUEST['list'])) {\n            return;\n        }\n\n        $podcasts = [];\n        foreach ($_POST['podlove_list']['podcasts'] as $podcast) {\n            $podcasts[] = $podcast;\n        }\n\n        $_POST['podlove_list']['podcasts'] = wp_json_encode($podcasts);\n\n        PodcastList::activate_network_scope();\n        $list = PodcastList::find_by_id($_REQUEST['list']);\n        $list->update_attributes($_POST['podlove_list']);\n        PodcastList::deactivate_network_scope();\n\n        $this->redirect('index', $list->id);\n    }\n\n    /**\n     * Process form: create a list.\n     */\n    private function create()\n    {\n        global $wpdb;\n\n        $podcasts = [];\n        foreach ($_POST['podlove_list']['podcasts'] as $podcast) {\n            $podcasts[] = $podcast;\n        }\n\n        $_POST['podlove_list']['podcasts'] = wp_json_encode($podcasts);\n\n        PodcastList::activate_network_scope();\n        $list = new PodcastList();\n        $list->update_attributes($_POST['podlove_list']);\n        PodcastList::deactivate_network_scope();\n\n        $this->redirect('index');\n    }\n\n    /**\n     * Process form: delete a list.\n     */\n    private function delete()\n    {\n        if (!isset($_REQUEST['list'])) {\n            return;\n        }\n\n        PodcastList::activate_network_scope();\n        PodcastList::find_by_id($_REQUEST['list'])->delete();\n        PodcastList::deactivate_network_scope();\n\n        $this->redirect('index');\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $list_id\n     */\n    private function redirect($action, $list_id = null)\n    {\n        $page = 'network/admin.php?page='.self::MENU_SLUG;\n        $show = ($list_id) ? '&list='.$list_id : '';\n        $action = '&action='.$action;\n\n        wp_redirect(admin_url($page.$show.$action));\n        exit;\n    }\n\n    private function view_template()\n    {\n        echo __('If you have configured a <a href=\"http://codex.wordpress.org/Create_A_Network\">\n\t\t\t\tWordPress Network</a>, Podlove allows you to configure Podcast lists.', 'podlove-podcasting-plugin-for-wordpress');\n        $table = new \\Podlove\\Modules\\Networks\\PodcastList_List_Table();\n        $table->prepare_items();\n        $table->display();\n    }\n\n    private function new_template()\n    {\n        PodcastList::activate_network_scope();\n        $list = new PodcastList(); ?>\n\t\t<h3><?php echo __('Add New list', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<?php\n        $this->form_template($list, 'create', __('Add New list', 'podlove-podcasting-plugin-for-wordpress'));\n        PodcastList::deactivate_network_scope();\n    }\n\n    private function edit_template()\n    {\n        PodcastList::activate_network_scope();\n        $list = PodcastList::find_by_id($_REQUEST['list']);\n        echo '<h3>'.sprintf(__('Edit list: %s', 'podlove-podcasting-plugin-for-wordpress'), $list->title).'</h3>';\n        $this->form_template($list, 'save');\n        PodcastList::deactivate_network_scope();\n    }\n\n    private function form_template($list, $action, $button_text = null)\n    {\n        $form_args = [\n            'context' => 'podlove_list',\n            'nonce' => self::$nonce,\n            'hidden' => [\n                'list' => $list->id,\n                'action' => $action\n            ],\n        ];\n\n        \\Podlove\\Form\\build_for($list, $form_args, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $list = $form->object;\n\n            $wrapper->string('slug', [\n                'label' => __('ID', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required'],\n                'description' => sprintf(__('For referencing in templates: %s', 'podlove-podcasting-plugin-for-wordpress'), '<code>{{ network.lists({id: \"example\"}).title }}</code>'),\n            ]);\n\n            $wrapper->string('title', [\n                'label' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required'],\n            ]);\n\n            $wrapper->string('subtitle', [\n                'label' => __('Subtitle', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text'],\n            ]);\n\n            $wrapper->text('description', [\n                'label' => __('Summary', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['rows' => 3, 'cols' => 40],\n            ]);\n\n            $wrapper->image('logo', [\n                'label' => __('Logo', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('JPEG or PNG.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text'],\n                'image_width' => 300,\n                'image_height' => 300,\n            ]);\n\n            $wrapper->string('url', [\n                'label' => __('List URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text'],\n            ]);\n\n            $wrapper->callback('podcasts', [\n                'label' => __('Podcasts', 'podlove-podcasting-plugin-for-wordpress'),\n                'callback' => function () use ($list) {\n                    $form_base_name = 'podlove_list'; ?>\n\t\t\t\t\t<div id=\"podcast_lists\">\n\t\t\t\t\t\t<table class=\"podlove_alternating\" border=\"0\" cellspacing=\"0\">\n\t\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t<th><?php echo __('Source', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t\t\t<th><?php echo __('Podcast/URL', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t\t\t<th style=\"width: 60px\"><?php echo __('Remove', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t\t\t<th style=\"width: 30px\"></th>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t<tbody class=\"podcasts_table_body\" style=\"min-height: 50px;\">\n\t\t\t\t\t\t\t\t<tr class=\"podcasts_table_body_placeholder\" style=\"display: none;\">\n\t\t\t\t\t\t\t\t\t<td><em><?php echo __('No Podcasts were added yet.', 'podlove-podcasting-plugin-for-wordpress'); ?></em></td>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\n\t\t\t\t\t\t<div id=\"add_new_podcasts_wrapper\">\n\t\t\t\t\t\t\t<input class=\"button\" id=\"add_new_podcast\" value=\"+\" type=\"button\" />\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<script type=\"text/template\" id=\"podcast-row-template\">\n\t\t\t\t\t\t<tr class=\"media_file_row podlove-podcast-table\" data-id=\"{{id}}\">\n\t\t\t\t\t\t\t<td class=\"podlove-podcast-column\">\n\t\t\t\t\t\t\t\t<select name=\"<?php echo $form_base_name; ?>[podcasts][{{id}}][type]\" class=\"podlove-podcast-dropdown\">\n\t\t\t\t\t\t\t\t\t<option value=\"wplist\" selected><?php echo __('WordPress Network', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"podlove-podcast-value\"></td>\n\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t<span class=\"podcast_remove\">\n\t\t\t\t\t\t\t\t\t<i class=\"clickable podlove-icon-remove\"></i>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"move column-move\"><i class=\"reorder-handle podlove-icon-reorder\"></i></td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</script>\n\t\t\t\t\t\t<script type=\"text/template\" id=\"podcast-select-type-wplist\">\n\t\t\t\t\t\t<select name=\"<?php echo $form_base_name; ?>[podcasts][{{id}}][podcast]\" class=\"podlove-podcast chosen-image\">\n\t\t\t\t\t\t\t<option>— <?php echo __('Select Podcast', 'podlove-podcasting-plugin-for-wordpress'); ?> —</option>\n\t\t\t\t\t\t\t<?php\n                                foreach (Network::podcasts() as $blog_id => $podcast) {\n                                    if ($podcast->title) {\n                                        printf(\"<option value='%s' data-img-src='%s'>%s</option>\\n\", $blog_id, $podcast->cover_art()->setWidth(45)->url(), $podcast->title);\n                                    }\n                                } ?>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</script>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<script type=\"text/javascript\">\n\n\t\t\t\t\t\tvar PODLOVE = PODLOVE || {};\n\n\t\t\t\t\t\t(function($) {\n\t\t\t\t\t\t\tvar i = 0;\n\t\t\t\t\t\t\tvar existing_podcasts = <?php echo is_null($list->podcasts) ? '[]' : $list->podcasts; ?>;\n\t\t\t\t\t\t\tvar podcasts = [];\n\n\t\t\t\t\t\t\tfunction update_chosen() {\n\t\t\t\t\t\t\t\t$(\".chosen\").chosen();\n\t\t\t\t\t\t\t\t$(\".chosen-image\").chosenImage();\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tfunction podcast_dropdown_handler() {\n\t\t\t\t\t\t\t\t$('select.podlove-podcast-dropdown').change(function() {\n\t\t\t\t\t\t\t\t\trow = $(this).closest(\"tr\");\n\t\t\t\t\t\t\t\t\tpodcast_source = $(this).val();\n\n\t\t\t\t\t\t\t\t\t// Check for empty podcast / for new field\n\t\t\t\t\t\t\t\t\tif (podcast_source === '') {\n\t\t\t\t\t\t\t\t\t\trow.find(\".podlove-podcast-value\").html(\"\"); // Empty podcast column and hide edit button\n\t\t\t\t\t\t\t\t\t\trow.find(\".podlove-podcast-edit\").hide();\n\t\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\tif (!row.find(\".podlove-podcast\").length) {\n\t\t\t\t\t\t\t\t\t\ttemplate_id = \"#podcast-select-type-\" + podcast_source;\n\t\t\t\t\t\t\t\t\t\ttemplate = $( template_id ).html();\n\t\t\t\t\t\t\t\t\t\ttemplate = template.replace(/\\{\\{id\\}\\}/g, row.data('id') );\n\n\t\t\t\t\t\t\t\t\t\trow.find(\".podlove-podcast-value\").html( template );\n\t\t\t\t\t\t\t\t\t\tupdate_chosen();\n\n\t\t\t\t\t\t\t\t\t\ti++; // continue using \"i\" which was already used to add the existing contributions\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t$(document).ready(function() {\n\t\t\t\t\t\t\t\t$(\"#podcast_lists table\").podloveDataTable({\n\t\t\t\t\t\t\t\t\trowTemplate: \"#podcast-row-template\",\n\t\t\t\t\t\t\t\t\tdeleteHandle: \".podcast_remove\",\n\t\t\t\t\t\t\t\t\tsortableHandle: \".reorder-handle\",\n\t\t\t\t\t\t\t\t\taddRowHandle: \"#add_new_podcast\",\n\t\t\t\t\t\t\t\t\tdata: existing_podcasts,\n\t\t\t\t\t\t\t\t\tdataPresets: podcasts,\n\t\t\t\t\t\t\t\t\tonRowLoad: function(o) {\n\t\t\t\t\t\t\t\t\t\ttemplate_id = \"#podcast-select-type-\" + o.entry.type;\n\t\t\t\t\t\t\t\t\t\ttemplate = $( template_id ).html();\n\t\t\t\t\t\t\t\t\t\trow_as_object = $(o.row)\n\n\t\t\t\t\t\t\t\t\t\trow_as_object.find(\".podlove-podcast-value\").html( template );\n\t\t\t\t\t\t\t\t\t\trow_as_object.find('select.podlove-podcast-dropdown option[value=\"' + o.entry.type + '\"]').attr('selected', 'selected');\n\n\t\t\t\t\t\t\t\t\t\tswitch ( o.entry.type ) {\n\t\t\t\t\t\t\t\t\t\t\tdefault: case 'wplist':\n\t\t\t\t\t\t\t\t\t\t\t\trow_as_object.find('select.podlove-podcast option[value=\"' + o.entry.podcast + '\"]').attr('selected', true);\n\t\t\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\to.row = row_as_object[0].outerHTML.replace(/\\{\\{id\\}\\}/g, i);\n\n\t\t\t\t\t\t\t\t\t\ti++;\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tonRowAdd: function(o) {\n\t\t\t\t\t\t\t\t\t\to.row = o.row.replace(/\\{\\{id\\}\\}/g, i);\n\n\n\t\t\t\t\t\t\t\t\t\trow = $(\".podcasts_table_body tr:last .podlove-podcast-dropdown\").focus();\n\n\t\t\t\t\t\t\t\t\t\tpodcast_dropdown_handler();\n\t\t\t\t\t\t\t\t\t\tupdate_chosen();\n\t\t\t\t\t\t\t\t\t\trow.change();\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tonRowDelete: function(tr) {\n\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t}(jQuery));\n\n\t\t\t\t\t</script>\n\t\t\t\t\t<?php\n                },\n            ]);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/settings/templates.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Settings;\n\nuse Podlove\\Model\\Template;\n\nclass Templates\n{\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        self::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Templates', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Templates', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_templates_settings_handle',\n            // $function\n            [$this, 'page']\n        );\n        add_action('admin_init', [$this, 'scripts_and_styles']);\n    }\n\n    public function scripts_and_styles()\n    {\n        if (!isset($_REQUEST['page'])) {\n            return;\n        }\n\n        if ($_REQUEST['page'] != 'podlove_templates_settings_handle') {\n            return;\n        }\n\n        wp_register_script('podlove-ace-js', \\Podlove\\PLUGIN_URL.'/js/admin/ace/ace.js');\n\n        wp_register_script('podlove-template-js', \\Podlove\\PLUGIN_URL.'/js/admin/template.js', ['jquery', 'podlove-ace-js']);\n\n        wp_localize_script(\n            'podlove-template-js',\n            'podlove_admin_network_global',\n            [\n                'is_network_admin' => is_network_admin()\n            ]\n        );\n\n        wp_enqueue_script('podlove-template-js');\n    }\n\n    public function page()\n    {\n        $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null; ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Templates', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\t\t\t<?php $this->view_template(); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    private function view_template()\n    {\n        echo __(\n            'Use network templates to share common templates in your podcast sites.\n\t\t\tThey are available in all podcast sites.\n\t\t\tIf you define a local template for a template ID that also exists network-wide, the local template takes precedence.',\n            'podlove-podcasting-plugin-for-wordpress'\n        );\n\n        $templates = Template::with_network_scope(function () {\n            return Template::all();\n        }); ?>\n\n\t\t<div id=\"template-editor\">\n\t\t\t<div class=\"navigation\">\n\t\t\t\t<ul>\n\t\t\t\t\t<?php foreach ($templates as $template) { ?>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<a href=\"#\" data-id=\"<?php echo $template->id; ?>\">\n\t\t\t\t\t\t\t\t<span class=\"filename\"><?php echo $template->title; ?></span>&nbsp;\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</ul>\n\t\t\t\t<div class=\"add\">\n\t\t\t\t\t<a href=\"#\">+ <?php _e('add new template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"editor\">\n\t\t\t\t<div class=\"toolbar\">\n\t\t\t\t\t<div class=\"title\">\n\t\t\t\t\t\t<input type=\"text\">\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"clear\"></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"editor-wrapper\">\n\t\t\t\t\t<div class=\"main\" id=\"ace-editor\"></div>\n\t\t\t\t\t<div id=\"fullscreen\" class=\"fullscreen-on fullscreen-button\"></div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"clear\"></div>\n\t\t\t<footer>\n\t\t\t  <div class=\"actions\">\n\t\t\t\t\t<a href=\"#\" class=\"save button button-primary\"><?php _e('Save Template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t  \t<a href=\"#\" class=\"delete\"><?php _e('Delete Template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t  </div>\n\t\t\t</footer>\n\t\t\t<div class=\"clear\"></div>\n\t\t</div>\n\n        <div class=\"podlove-template-shortcode\" style=\"margin-top: 8px\">\n\t\t  <div>\n\t  \t\t<strong><?php _e('Embed with Shortcode', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</div>\n\t\t  <div style=\"margin-top: 4px; display: flex\">\n\t\t\t\t<input id=\"podlove_template_shortcode_preview\" class=\"regular-text code\" value=\"\" style=\"margin-right: 8px\">\n\n\t\t\t\t<button class=\"button clipboard-btn\" data-clipboard-target=\"#podlove_template_shortcode_preview\">\n\t\t\t\t\tCopy to Clipboard\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/template/network.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Template;\n\nuse Podlove\\Modules\\Networks\\Model as NetworksModel;\nuse Podlove\\Modules\\Networks\\Template as NetworksTemplate;\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Network Template Wrapper.\n *\n * Requires the \"Networks\" module.\n *\n * @templatetag network\n */\nclass Network extends Wrapper\n{\n    public function __construct() {}\n\n    /**\n     * Network Lists.\n     *\n     * List network lists.\n     * Use the `slug` parameter to access a specific list.\n     *\n     * **Examples**\n     *\n     * Iterate over all lists.\n     *\n     * ```jinja\n     * {% for list in network.lists %}\n     *     {{ list.title }}\n     * {% endfor %}\n     * ```\n     *\n     * Access a specific list by id.\n     *\n     * ```jinja\n     * {{ network.lists({id: \"example\"}).title }}\n     * ```\n     *\n     * @see list\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function lists($args = [])\n    {\n        NetworksModel\\PodcastList::activate_network_scope();\n\n        if (isset($args['id'])) {\n            if ($list = NetworksModel\\PodcastList::find_one_by_slug($args['id'])) {\n                return new NetworksTemplate\\PodcastList($list);\n            }\n        }\n\n        $lists = [];\n        foreach (NetworksModel\\PodcastList::all() as $list) {\n            $lists[] = new PodcastList($list);\n        }\n\n        NetworksModel\\PodcastList::deactivate_network_scope();\n\n        return $lists;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "lib/modules/networks/template/podcast_list.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Networks\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * List Template Wrapper.\n *\n * Requires the \"Networks\" module.\n *\n * @templatetag list\n */\nclass PodcastList extends Wrapper\n{\n    /**\n     * @var \\Podlove\\Modules\\Networks\\Model\\PodcastList\n     */\n    private $list;\n\n    public function __construct($list)\n    {\n        $this->list = $list;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * List title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->list->title;\n    }\n\n    /**\n     * List subtitle.\n     *\n     * @accessor\n     */\n    public function subtitle()\n    {\n        return $this->list->subtitle;\n    }\n\n    /**\n     * List summary.\n     *\n     * @accessor\n     */\n    public function summary()\n    {\n        return $this->list->description;\n    }\n\n    /**\n     * List description.\n     *\n     * @deprecated since 2.3, use summary instead\n     */\n    public function description()\n    {\n        return $this->list->description;\n    }\n\n    /**\n     * List logo.\n     *\n     * @accessor\n     */\n    public function logo()\n    {\n        if ($this->list->logo) {\n            $logo = new \\Podlove\\Model\\Image($this->list->logo);\n\n            return new \\Podlove\\Template\\Image($logo);\n        }\n\n        return null;\n    }\n\n    /**\n     * List url.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->list->url;\n    }\n\n    /**\n     * List podcasts.\n     *\n     * @accessor\n     */\n    public function podcasts()\n    {\n        return array_map(function ($podcast) {\n            return new \\Podlove\\Template\\Podcast($podcast);\n        }, $this->list->podcasts());\n    }\n\n    /**\n     * List latest episodes from network.\n     *\n     * - limit:   Maximum number of episodes. Default: 10.\n     * - orderby: Order episodes by 'post_date', 'post_title', 'ID' or 'comment_count'. Default: 'post_date'.\n     * - order: Designates the ascending or descending order of the 'orderby' parameter. Default: 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        $number_of_episodes = isset($args['limit']) && is_numeric($args['limit']) ? $args['limit'] : 10;\n        $orderby = isset($args['orderby']) && $args['orderby'] ? $args['orderby'] : 'post_date';\n        $order = isset($args['order']) && $args['order'] ? $args['order'] : 'DESC';\n\n        return $this->list->latest_episodes($number_of_episodes, $orderby, $order);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "lib/modules/notifications/mailer_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Notifications;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Log;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\n\nclass MailerJob\n{\n    use JobTrait;\n\n    public function setup()\n    {\n        $this->hooks['init'] = [$this, 'init_job'];\n    }\n\n    public static function title()\n    {\n        return __('Sending Notification E-Mails', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function description()\n    {\n        return __('Sends notification emails to contributors.', 'podlove-podcasting-plugin-for-wordpress');\n    }\n\n    public static function mode($args)\n    {\n        if (isset($args['debug']) && $args['debug']) {\n            return __('Test', 'podlove-podcasting-plugin-for-wordpress');\n        }\n\n        return '';\n    }\n\n    public function get_total_steps()\n    {\n        return count($this->job->args['contributors']);\n    }\n\n    public function init_job()\n    {\n        // todo: verify episode and contributors params exist and are valid; abort and log otherwise\n        $this->job->state = [\n            'contributors_todo' => $this->job->args['contributors'],\n        ];\n    }\n\n    public static function log_mailer_errors($wp_error)\n    {\n        Log::get()->addWarning('Sending email failed. Reason: '.$wp_error->get_error_message(), [\n            'module' => 'E-Mail Notifications',\n            'wp_error' => $wp_error,\n        ]);\n    }\n\n    public function getReceiver(Contributor $contributor)\n    {\n        if (!$this->isDebug()) {\n            return $contributor->getMailAddress();\n        }\n\n        return $this->job->args['debug_receiver'];\n    }\n\n    public function isDebug()\n    {\n        return ($this->job->args['debug'] ?? false) && ($this->job->args['debug_receiver'] ?? false);\n    }\n\n    public function getSubject()\n    {\n        $subject = \\Podlove\\get_setting('notifications', 'subject');\n        $subject = \\Podlove\\Template\\TwigFilter::apply_to_html($subject);\n\n        if ($this->isDebug()) {\n            $subject = '[TEST] '.$subject;\n        }\n\n        return $subject;\n    }\n\n    public function getHeaders()\n    {\n        return [\n            'Content-Type: text/plain; charset=UTF-8',\n            'From: '.self::getSenderAddress(),\n        ];\n    }\n\n    public function getMessage()\n    {\n        $message = \\Podlove\\get_setting('notifications', 'body');\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html($message);\n    }\n\n    public static function getSenderAddress()\n    {\n        $default = get_option('admin_email');\n        $sender_id = \\Podlove\\get_setting('notifications', 'send_as');\n        $sender = Contributor::find_by_id($sender_id);\n\n        if (!$sender) {\n            return $default;\n        }\n\n        $address = $sender->getMailAddress();\n\n        if (!$address) {\n            return $default;\n        }\n\n        return $address;\n    }\n\n    protected function do_step()\n    {\n        // fetch next contributor to receive notification and save state\n        $contributors_todo = $this->job->state['contributors_todo'];\n        $contributor_id = array_pop($contributors_todo);\n        $contributor = Contributor::find_by_id($contributor_id);\n\n        $this->job->update_state('contributors_todo', $contributors_todo);\n\n        self::register_log_mailer_errors();\n        $this->prepare_and_send_mail($contributor);\n        self::deregister_log_mailer_errors();\n\n        return 1;\n    }\n\n    private static function register_log_mailer_errors()\n    {\n        add_action('wp_mail_failed', [__CLASS__, 'log_mailer_errors']);\n    }\n\n    private static function deregister_log_mailer_errors()\n    {\n        remove_action('wp_mail_failed', [__CLASS__, 'log_mailer_errors']);\n    }\n\n    private function prepare_and_send_mail(Contributor $contributor)\n    {\n        global $post; // required for setup_postdata()\n\n        $episode = Episode::find_by_id($this->job->args['episode']);\n        $post = get_post($episode->post_id);\n\n        if (!$contributor->privateemail) {\n            Log::get()->addWarning('Tried sending email notification to '.$contributor->getName().'. Unsuccessful due to missing contact email.', [\n                'module' => 'E-Mail Notifications',\n                'contributor_id' => $contributor->id,\n                'contributor_name' => $contributor->getName(),\n                'episode_id' => $episode->id,\n                'episode_title' => $episode->title(),\n            ]);\n\n            return;\n        }\n\n        setup_postdata($post);\n\n        // add contributor to message context\n        $add_contribtutor_to_context = function ($context) use ($contributor) {\n            $context['contributor'] = new \\Podlove\\Modules\\Contributors\\Template\\Contributor($contributor);\n\n            return $context;\n        };\n\n        add_filter('podlove_templates_global_context', $add_contribtutor_to_context);\n\n        $success = wp_mail(\n            $this->getReceiver($contributor),\n            $this->getSubject(),\n            $this->getMessage(),\n            $this->getHeaders()\n        );\n\n        remove_filter('podlove_templates_global_context', $add_contribtutor_to_context);\n\n        if (!$success) {\n            Log::get()->addWarning('Tried sending email notification to '.$contributor->getName().'. wp_mail was unable to send.', [\n                'module' => 'E-Mail Notifications',\n                'contributor_id' => $contributor->id,\n                'contributor_name' => $contributor->getName(),\n                'episode_id' => $episode->id,\n                'episode_title' => $episode->title(),\n            ]);\n        }\n\n        wp_reset_postdata();\n    }\n}\n"
  },
  {
    "path": "lib/modules/notifications/notifications.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Notifications;\n\nuse Podlove\\Jobs\\CronJobRunner;\nuse Podlove\\Log;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\n\nclass Notifications extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'E-Mail Notifications';\n    protected $module_description = 'Notify contributors via E-Mail when episodes get published.';\n    protected $module_group = 'system';\n\n    public function load()\n    {\n        add_action('publish_podcast', [$this, 'maybe_send_notifications'], 10, 2);\n        add_action('podlove_module_was_activated_notifications', [$this, 'mark_existing_episodes_as_sent']);\n\n        add_filter('podlove_contributor_settings_tabs', function ($tabs) {\n            $tabs->addTab(new \\Podlove\\Modules\\Notifications\\SettingsTab(__('E-Mail Notifications', 'podlove-podcasting-plugin-for-wordpress')));\n\n            return $tabs;\n        });\n\n        add_action('podlove_notifications_start_mailer', [$this, 'start_mailer']);\n\n        if (isset($_REQUEST['podlove_notifications_test'], $_REQUEST['podlove_notifications_test']['episode'])) {\n            $this->send_test_notifications();\n        }\n    }\n\n    public function send_test_notifications()\n    {\n        $episode_id = (int) $_REQUEST['podlove_notifications_test']['episode'];\n        $receiver = trim($_REQUEST['podlove_notifications_test']['receiver']);\n\n        if (!$episode_id || !$receiver) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($episode_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        $contributors = $this->get_contributors_to_be_notified($episode);\n\n        // stop if there is no one to be notified\n        if (!count($contributors)) {\n            return;\n        }\n\n        $contributor_ids = array_map(function ($c) {\n            return $c->id;\n        }, $contributors);\n\n        $args = [\n            'contributors' => $contributor_ids,\n            'episode' => $episode->id,\n            'debug' => true,\n            'debug_receiver' => $receiver,\n        ];\n\n        CronJobRunner::create_job('\\Podlove\\Modules\\Notifications\\MailerJob', $args);\n    }\n\n    public function maybe_send_notifications($post_id, $post)\n    {\n        if ($this->notifications_sent($post_id)) {\n            Log::get()->addDebug(\"Did not send emails for post {$post_id} (\".get_the_title($post_id).') because they were already sent.', [\n                'module' => 'E-Mail Notifications',\n            ]);\n\n            return;\n        }\n\n        $this->mark_notifications_sent($post_id);\n\n        $episode = Episode::find_one_by_property('post_id', (int) $post_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        $contributors = $this->get_contributors_to_be_notified($episode);\n\n        // stop if there is no one to be notified\n        if (!count($contributors)) {\n            Log::get()->addDebug(\"Did not send emails for post {$post_id} (\".get_the_title($post_id).') because no contributors exist or match the criteria.', [\n                'module' => 'E-Mail Notifications',\n            ]);\n\n            return;\n        }\n\n        $contributor_ids = array_map(function ($c) {\n            return $c->id;\n        }, $contributors);\n\n        $delay = (int) \\Podlove\\get_setting('notifications', 'delay');\n        $delay = $delay * MINUTE_IN_SECONDS;\n\n        $job_args = [\n            'contributors' => $contributor_ids,\n            'episode' => $episode->id,\n        ];\n\n        wp_schedule_single_event(time() + $delay, 'podlove_notifications_start_mailer', [$job_args]);\n    }\n\n    public function start_mailer($args)\n    {\n        Log::get()->addDebug('Start Mailer Job', [\n            'module' => 'E-Mail Notifications',\n        ]);\n        CronJobRunner::create_job('\\Podlove\\Modules\\Notifications\\MailerJob', $args);\n    }\n\n    /**\n     * Add \"sent\" token to all existing published episodes.\n     */\n    public function mark_existing_episodes_as_sent()\n    {\n        $args = [\n            'post_type' => 'podcast',\n            'post_status' => ['publish', 'private'],\n            'posts_per_page' => -1,\n            'fields' => 'ids',\n        ];\n\n        $post_ids = get_posts($args);\n\n        foreach ($post_ids as $post_id) {\n            $this->mark_notifications_sent($post_id);\n        }\n    }\n\n    /**\n     * Were notifications for this episode sent already?\n     *\n     * @param int $post_id\n     *\n     * @return bool true if notifications were sent, otherwise false\n     */\n    public function notifications_sent($post_id)\n    {\n        return (bool) get_post_meta($post_id, '_podlove_notifications_sent', true);\n    }\n\n    /**\n     * Remember that notifications have been sent.\n     *\n     * @param int $post_id\n     */\n    public function mark_notifications_sent($post_id)\n    {\n        update_post_meta($post_id, '_podlove_notifications_sent', true);\n    }\n\n    private function get_contributors_to_be_notified(Episode $episode)\n    {\n        $role_filter = (int) \\Podlove\\get_setting('notifications', 'role');\n        $group_filter = (int) \\Podlove\\get_setting('notifications', 'group');\n        $always_send_to = (array) \\Podlove\\get_setting('notifications', 'always_send_to');\n        $always_send_to = array_filter($always_send_to);\n\n        $contributions = EpisodeContribution::find_all_by_episode_id($episode->id);\n\n        // filter by role\n        if ($role_filter) {\n            $contributions = array_filter($contributions, function ($c) use ($role_filter) {\n                return $c->role_id == $role_filter;\n            });\n        }\n\n        // filter by group\n        if ($group_filter) {\n            $contributions = array_filter($contributions, function ($c) use ($group_filter) {\n                return $c->group_id == $group_filter;\n            });\n        }\n\n        // map contributions to contributor ids\n        $contributor_ids = array_map(function ($c) {\n            return $c->contributor_id;\n        }, $contributions);\n\n        // add permanent contributor ids\n        foreach ($always_send_to as $always_contributor_id) {\n            if (!in_array($always_contributor_id, $contributor_ids)) {\n                $contributor_ids[] = $always_contributor_id;\n            }\n        }\n\n        // map contributor ids to contributors\n        return array_map(function ($id) {\n            return Contributor::find_by_id($id);\n        }, $contributor_ids);\n    }\n}\n"
  },
  {
    "path": "lib/modules/notifications/settings_tab.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Notifications;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorGroup;\nuse Podlove\\Modules\\Contributors\\Model\\ContributorRole;\nuse Podlove\\Modules\\Contributors\\Settings\\ContributorSettings;\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass SettingsTab extends Tab\n{\n    public function get_slug()\n    {\n        return 'notifications';\n    }\n\n    public function page()\n    {\n        parent::page();\n\n        $debug_hook = self::debug_hook(); ?>\n\t\t<form method=\"post\" action=\"<?php echo admin_url('admin.php?page=podlove_contributor_settings&podlove_tab='.$this->get_slug()); ?>\">\n\t\t\t<?php if (isset($_REQUEST['podlove_tab'])) { ?>\n\t\t\t\t<input type=\"hidden\" name=\"podlove_tab\" value=\"<?php echo esc_attr($_REQUEST['podlove_tab']); ?>\" />\n\t\t\t<?php } ?>\n\n\t\t\t<?php settings_fields($debug_hook); ?>\n\t\t\t<?php do_settings_sections($debug_hook); ?>\n\n\t\t\t<?php submit_button(__('Send Test Emails', 'podlove-podcasting-plugin-for-wordpress'), 'button', 'submit', true); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    public static function settings_hook()\n    {\n        return ContributorSettings::$pagehook;\n    }\n\n    public static function debug_hook()\n    {\n        return ContributorSettings::$pagehook.'_debug';\n    }\n\n    public function init()\n    {\n        $hook = self::settings_hook();\n        $debug_hook = self::debug_hook();\n        $contributors = Contributor::all();\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_delay',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('E-Mail Notification Settings', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            $hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_notifications_delay',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Delay notifications', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_notifications[delay]\" id=\"podlove_delay\" type=\"number\" value=\"<?php echo esc_attr(\\Podlove\\get_setting('notifications', 'delay')); ?>\" class=\"text\"> <?php _e('minutes', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('Delay in minutes after an episode is published before notification e-mails are sent. Note that it may always take a few minutes longer than specified until e-mails are sent out.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_delay'\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_content',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Content', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('Additionally to %sall standard template tags%s you have access to the receiving %scontributor%s in notification content.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    '<a href=\"http://docs.podlove.org/podlove-publisher/reference/template-tags.html\" target=\"_blank\">',\n\t\t\t\t\t\t    '</a>',\n\t\t\t\t\t\t    '<a href=\"http://docs.podlove.org/podlove-publisher/reference/template-tags.html#contributor\" target=\"_blank\">',\n\t\t\t\t\t\t    '</a>'\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t</span>\n\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_settings_notifications_subject',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Subject', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input type=\"text\" name=\"podlove_notifications[subject]\" value=\"<?php echo esc_attr(\\Podlove\\get_setting('notifications', 'subject')); ?>\" class=\"text large-text\">\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_content'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_settings_notifications_body',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Message', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<textarea name=\"podlove_notifications[body]\" class=\"large-text\"><?php echo esc_html(\\Podlove\\get_setting('notifications', 'body')); ?></textarea>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_content'\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_sender',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Sender', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('Send e-mails with given contributor\\'s name and e-mail.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_settings_notifications_send_as',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Send as', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () use ($contributors) {\n                ?>\n\t\t\t\t<select name=\"podlove_notifications[send_as]\" class=\"chosen-image podlove-contributor-dropdown\" style=\"width: 220px;\">\n\t\t\t\t\t<option value=\"\"><?php echo __('Choose Contributor', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($contributors as $contributor) { ?>\n\t\t\t\t\t\t<option value=\"<?php echo $contributor->id; ?>\" data-img-src=\"<?php echo $contributor->avatar()->setWidth(10)->url(); ?>\" <?php selected(\\Podlove\\get_setting('notifications', 'send_as'), $contributor->id); ?>><?php echo $contributor->getName(); ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_sender'\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_always_send_to',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Always send to...', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('These contributors will always receive e-mails.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_settings_notifications_send_as',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Contributors', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () use ($contributors) {\n                $values = \\Podlove\\get_setting('notifications', 'always_send_to');\n                if (!is_array($values)) {\n                    $values = [];\n                }\n                $values = array_filter($values); ?>\n                <div id=\"always_send_to_list_wrapper\">\n                <?php foreach ($values as $selected_contributor_id) { ?>\n\t\t\t\t<select name=\"podlove_notifications[always_send_to][]\" class=\"chosen-image podlove-contributor-dropdown\" style=\"width: 220px;\">\n\t\t\t\t\t<option value=\"\"><?php echo __('Clear', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($contributors as $contributor) { ?>\n                        <?php\n                        $selected = selected($selected_contributor_id, $contributor->id);\n\t\t\t\t\t    $avatar = $contributor->avatar()->setWidth(10)->url();\n\t\t\t\t\t    $name = $contributor->getName();\n\t\t\t\t\t    ?>\n\t\t\t\t\t\t<option value=\"<?php echo $contributor->id; ?>\" data-img-src=\"<?php echo $avatar; ?>\" <?php echo $selected; ?>><?php echo $name; ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n                <?php } ?>\n                </div>\n\n                <p>\n                    <input type=\"button\" name=\"add_always_send_to\" id=\"add_always_send_to\" class=\"button\" value=\"Add another Contributor\">\n                </p>\n\n                <template id=\"contributor_selector\">\n\t\t\t\t<select name=\"podlove_notifications[always_send_to][]\" class=\"chosen-image podlove-contributor-dropdown\" style=\"width: 220px;\">\n\t\t\t\t\t<option value=\"\"><?php echo __('Choose Contributor', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($contributors as $contributor) { ?>\n                        <?php\n\t\t\t\t\t    $avatar = $contributor->avatar()->setWidth(10)->url();\n\t\t\t\t\t    $name = $contributor->getName();\n\t\t\t\t\t    ?>\n\t\t\t\t\t\t<option value=\"<?php echo $contributor->id; ?>\" data-img-src=\"<?php echo $avatar; ?>\"><?php echo $name; ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n                </template>\n\n\t\t\t\t<script type=\"text/javascript\">\n\t\t\t\t(function($) {\n\t\t\t\t\t$(\".chosen\").chosen({ width: '100%' });\n\t\t\t\t\t$(\".chosen-image\").chosenImage();\n\n                    const add_btn = document.getElementById('add_always_send_to');\n                    const container = document.getElementById('always_send_to_list_wrapper')\n                    const template = document.getElementById('contributor_selector')\n                    add_btn.addEventListener('click', () => {\n                        const newNode = template.content.cloneNode(true);\n                        container.appendChild(newNode)\n                        $(\"#always_send_to_list_wrapper .chosen\").chosen({ width: '100%' });\n\t\t\t\t\t    $(\"#always_send_to_list_wrapper .chosen-image\").chosenImage();\n                    })\n\t\t\t\t}(jQuery));\n\t\t\t\t</script>\n                <style>\n                #always_send_to_list_wrapper {\n                  display: flex;\n                  flex-direction: column;\n                  gap: 0.75rem;\n                  margin-bottom: 0.75rem;\n                }\n                </style>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_always_send_to'\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_recipients',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Recipients', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('Send e-mails to contributors of an episode. Send to either everyone or just contributors with a certain group or role.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_notifications_group',\n            // $title\n            sprintf(\n                '<label>%s</label>',\n                __('Group', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $groups = ContributorGroup::all(); ?>\n\t\t\t\t<select name=\"podlove_notifications[group]\">\n\t\t\t\t\t<option value=\"0\"><?php _e('All Groups', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($groups as $group) { ?>\n\t\t\t\t\t\t<option value=\"<?php echo esc_attr($group->id); ?>\" <?php selected(\\Podlove\\get_setting('notifications', 'group'), $group->id); ?>><?php echo esc_html($group->title); ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_recipients'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_notifications_role',\n            // $title\n            sprintf(\n                '<label>%s</label>',\n                __('Role', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $roles = ContributorRole::all(); ?>\n\t\t\t\t<select name=\"podlove_notifications[role]\">\n\t\t\t\t\t<option value=\"0\"><?php _e('All Roles', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($roles as $role) { ?>\n\t\t\t\t\t\t<option value=\"<?php echo esc_attr($role->id); ?>\" <?php selected(\\Podlove\\get_setting('notifications', 'role'), $role->id); ?>><?php echo esc_html($role->title); ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n\t\t\t\t<?php\n            },\n            // $page\n            $hook,\n            // $section\n            'podlove_settings_notifications_recipients'\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_notifications_test',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Testing', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('Send test emails to see if everything works as expected. Sends all emails based on contributors in selected episode but receiver is always the one configured here in the test section.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            $debug_hook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_notifications_test_episode',\n            // $title\n            sprintf(\n                '<label>%s</label>',\n                __('Episode', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $episodes = Episode::find_all_by_time(); ?>\n\t\t\t\t<select name=\"podlove_notifications_test[episode]\">\n\t\t\t\t\t<option value=\"0\"><?php _e('Select Episode', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t<?php foreach ($episodes as $episode) { ?>\n\t\t\t\t\t\t<option value=\"<?php echo esc_attr($episode->id); ?>\" <?php selected(\\Podlove\\get_setting('notifications_test', 'episode'), $episode->id); ?>><?php echo esc_html($episode->title()); ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</select>\n\t\t\t\t<?php\n            },\n            // $page\n            $debug_hook,\n            // $section\n            'podlove_settings_notifications_test'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_notifications_test_receiver',\n            // $title\n            sprintf(\n                '<label for=\"podlove_delay\">%s</label>',\n                __('Receiver', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_notifications_test[receiver]\" type=\"email\" value=\"<?php echo esc_attr(\\Podlove\\get_setting('notifications_test', 'receiver')); ?>\" class=\"text\">\n\t\t\t\t<?php\n            },\n            // $page\n            $debug_hook,\n            // $section\n            'podlove_settings_notifications_test'\n        );\n\n        register_setting($hook, 'podlove_notifications');\n        register_setting($debug_hook, 'podlove_notifications_test');\n    }\n}\n"
  },
  {
    "path": "lib/modules/oembed/oembed.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\oembed;\n\nuse Podlove\\DomDocumentFragment;\n\nclass oembed extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'oEmbed Support';\n    protected $module_description = 'Allows an embedded representation of a URL on third party sites.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('wp', [$this, 'load_oembed']);\n        add_action('wp_head', [$this, 'register_oembed_discovery']);\n    }\n\n    public function load_oembed()\n    {\n        if (!is_single()) {\n            return;\n        }\n\n        if (!isset($_GET['service']) || strtoupper($_GET['service']) != 'PODLOVE-OEMBED' || !isset($_GET['format'])) {\n            return;\n        }\n\n        $episode = $this->get_current_episode(get_the_ID());\n\n        if (!$episode) {\n            return;\n        }\n\n        switch (strtoupper($_GET['format'])) {\n            case 'JSON':\n                header('Content-Type: application/json; charset=utf-8');\n                print_r(wp_json_encode($episode));\n                exit;\n\n                break;\n            case 'XML':\n                header('Content-Type: application/xml; charset=utf-8');\n                $xml_source = new \\SimpleXMLElement('<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><oembed/>');\n\n                foreach ($episode as $key => $value) {\n                    $xml_source->{$key} = $value;\n                }\n\n                echo $xml_source->asXML();\n                exit;\n\n                break;\n\n            default:\n                status_header(404);\n\n                break;\n        }\n\n        exit;\n    }\n\n    public function get_current_episode($post_id)\n    {\n        if (get_post_status($post_id) !== 'publish' || get_post_type($post_id) !== 'podcast') {\n            status_header(404);\n            exit;\n        }\n\n        $episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post_id);\n        $podcast = \\Podlove\\Model\\Podcast::get();\n        $permalink = get_permalink($post_id);\n\n        $player_width = '560px';\n        $player_height = '140px';\n\n        $src = $permalink.(strpos($permalink, '?') === false ? '?' : '&amp;');\n\n        // fixme: the iframe implementation with \"standalonePlayer\" parameter only works for PWP2\n        return [\n            'version' => '1.0',\n            'type' => 'rich',\n            'width' => $player_width,\n            'height' => $player_height,\n            'title' => $episode->full_title(),\n            'url' => get_permalink($post_id),\n            'author_name' => $podcast->full_title(),\n            'author_url' => site_url(),\n            'thumbnail_url' => $episode->cover_art_with_fallback()->url(),\n            'html' => '<iframe width=\"'.$player_width.'\" height=\"'.$player_height.'\" src=\"'.$src.'standalonePlayer\"></iframe>',\n        ];\n    }\n\n    public function register_oembed_discovery()\n    { // WordPress does not allow registering custom <link> elements.\n        if (!is_single()) {\n            return;\n        }\n\n        $post_id = get_the_ID();\n\n        if (get_post_type($post_id) !== 'podcast') {\n            return;\n        }\n\n        $permalink = get_permalink($post_id);\n        $permalink_template = $permalink.(strpos($permalink, '?') === false ? '?' : '&');\n        $title = get_the_title($post_id);\n\n        $embed_elements = [\n            [\n                'rel' => 'alternate',\n                'type' => 'application/json+oembed',\n                'href' => $permalink_template.'service=podlove-oembed&format=json',\n                'title' => $title.' oEmbed Profile',\n            ],\n            [\n                'rel' => 'alternate',\n                'type' => 'application/xml+oembed',\n                'href' => $permalink_template.'service=podlove-oembed&format=xml',\n                'title' => $title.' oEmbed Profile',\n            ],\n        ];\n\n        $dom = new DomDocumentFragment();\n\n        foreach ($embed_elements as $link_element) {\n            $element = $dom->createElement('link');\n            foreach ($link_element as $attribute => $value) {\n                $element->setAttribute($attribute, $value);\n            }\n            $dom->appendChild($element);\n        }\n\n        echo $dom;\n    }\n}\n"
  },
  {
    "path": "lib/modules/onboarding/css/podlove-onboarding-banner.css",
    "content": ".podlove-panel-banner {\n  display: flex;\n  align-items: center;\n  background-color: rgba(255, 255, 255, 0.8);\n}\n\n.podlove-panel-banner-left {\n  padding: 2rem;\n}\n\n.podlove-panel-banner-right {\n  padding: 2rem 0rem 2rem 2rem;\n}\n\n.podlove-panel-banner-image {\n  object-fit: cover;\n  display: flex;\n  justify-content: left;\n  width: 200px;\n  height: 200px;\n}\n\n.podlove-panel-banner-head {\n  margin-top: 0.5rem;\n  margin-bottom: 0.5rem;\n  font-size: 1.875rem;\n  line-height: 2.25rem;\n}\n\n.podlove-panel-banner-text {\n  font-size: 1rem;\n  line-height: 1.75rem;\n  margin-bottom: 1.5rem;\n  padding-right: 2rem;\n}\n\n.podlove-panel-banner-dismiss {\n  position: absolute;\n  color: #c3dafe;\n  top: 1rem;\n  right: 1rem;\n  text-decoration: none;\n  z-index: 2;\n}\n\n.podlove-panel-banner-dismiss::before {\n  transition: all 0.1s ease-in-out;\n  content: '\\f335';\n  font: normal 21px dashicons;\n}\n\n.podlove-panel-banner-dismiss::hover,\n.podlove-panel-banner-dismiss::focus,\n.podlove-panel-banner-dismiss::hover::before,\n.podlove-panel-banner-dismiss::focus::before {\n  color: #434190;\n  background: #fff;\n}\n\n.podlove-panel-banner-button {\n  border-radius: 0.375rem;\n  padding: 0.75rem 1.5rem;\n  font-size: 1rem;\n  line-height: 1.5rem;\n  font-weight: 500;\n  color: #fff;\n  background: #4f46e5;\n  text-decoration: none;\n}\n\n.podlove-panel-banner-button:hover,\n.podlove-panel-banner-button:focus {\n  color: #fff;\n  background: #4338ca;\n}\n\n.podlove-panel-description {\n  display: flex;\n  align-items: flex-start;\n  margin: 20px;\n}\n\n.podlove-panel-text {\n  display: flex;\n  flex-direction: column;\n}\n\n.podlove-panel-wrap {\n  margin: 10px 20px 0 2px;\n  padding-top: 1rem;\n}\n\n.podlove-panel {\n  position: relative;\n  overflow: auto;\n  margin: 16px 0;\n  background-color: #fff;\n  font-size: 1rem;\n  line-height: 1.3;\n  clear: both;\n}\n\n.podlove-panel h2 {\n  margin: 0;\n  font-size: 48px;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.podlove-panel h3 {\n  margin: 0;\n  font-size: 20px;\n  font-weight: 400;\n  line-height: 1.4;\n}\n\n.podlove-panel p {\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.podlove-panel-header {\n  position: relative;\n}\n\n.podlove-panel-header-image {\n  position: absolute !important;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  z-index: 0 !important;\n  overflow: hidden;\n}\n\n.podlove-panel-header-image svg {\n  display: block;\n  margin: auto;\n  width: 100%;\n  height: 100%;\n}\n\n.podlove-panel-header * {\n  position: relative;\n  z-index: 1;\n}\n\n.podlove-panel-content {\n  min-height: 220px;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n}\n\n.podlove-panel-header {\n  box-sizing: border-box;\n  margin-left: auto;\n  margin-right: auto;\n  max-width: 1500px;\n  width: 100%;\n  padding: 1rem 0 2rem 1rem;\n}\n"
  },
  {
    "path": "lib/modules/onboarding/onboarding.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Onboarding;\n\nuse Podlove\\Modules\\Onboarding\\Settings\\OnboardingPage;\n\nclass Onboarding extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Onboarding';\n    protected $module_description = 'Handling the onboarding to the Podlove Publisher';\n    protected $module_group = 'system';\n\n    public function load()\n    {\n        add_action('admin_enqueue_scripts', [$this, 'add_scripts_and_styles']);\n        add_action('admin_notices', [$this, 'onboarding_banner']);\n        add_action('admin_menu', [$this, 'add_onboarding_menu'], 20);\n        add_action('rest_api_init', [$this, 'api_init']);\n    }\n\n    public static function is_visible()\n    {\n        return true;\n    }\n\n    public function onboarding_banner()\n    {\n        if (self::is_banner_hide()) {\n            return;\n        }\n\n        if (apply_filters('podlove_admin_promo_banner_active', false)) {\n            return;\n        }\n\n        if (isset($_REQUEST['page']) && $_REQUEST['page'] === 'podlove_settings_onboarding_handle') {\n            return;\n        } ?>\n\n    <div id=\"podlove-panel-wrap\" class=podlove-panel-wrap>\n      <div class=\"podlove-panel\">\n        <?php\n              echo sprintf(\n                  '<a id=\"podlove-panel-banner-dismiss\" class=\"podlove-panel-banner-dismiss\" href=\"#\"></a>'\n              ); ?>\n        <div class=\"podlove-panel-content\">\n          <div class=\"podlove-panel-header\">\n            <div class=\"podlove-panel-header-image\">\n              <img src=\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='1440' height='560' preserveAspectRatio='none' viewBox='0 0 1440 560'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1092%26quot%3b)' fill='none'%3e%3cpath d='M272 242L271 -68' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M121 369L120 691' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1223 512L1222 310' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1055 343L1054 -1' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M1438 141L1437 -37' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M339 160L338 -21' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1080 22L1079 -337' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M300 467L299 274' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1438 372L1437 790' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M296 497L295 755' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M887 301L886 0' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M286 172L285 18' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M732 256L731 -48' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M685 394L684 766' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M1186 526L1185 354' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M353 292L352 113' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M253 447L252 646' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M687 471L686 640' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1272 166L1271 457' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M927 173L926 385' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1409 204L1408 483' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M847 434L846 127' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M437 224L436 -140' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M126 67L125 -324' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M804 35L803 -197' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M983 242L982 7' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M649 102L648 -247' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M697 282L696 551' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M1149 236L1148 19' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M333 420L332 157' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M1396 100L1395 -189' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M797 117L796 -74' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1094%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M1345 459L1344 43' stroke-width='10' stroke='url(%26quot%3b%23SvgjsLinearGradient1096%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M257 131L256 -32' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3cpath d='M99 9L98 -234' stroke-width='8' stroke='url(%26quot%3b%23SvgjsLinearGradient1095%26quot%3b)' stroke-linecap='round' class='Down'%3e%3c/path%3e%3cpath d='M723 435L722 56' stroke-width='6' stroke='url(%26quot%3b%23SvgjsLinearGradient1093%26quot%3b)' stroke-linecap='round' class='Up'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1092'%3e%3crect width='1440' height='560' fill='white'%3e%3c/rect%3e%3c/mask%3e%3clinearGradient x1='0%25' y1='100%25' x2='0%25' y2='0%25' id='SvgjsLinearGradient1093'%3e%3cstop stop-color='rgba(67%2c 56%2c 202%2c 0)' offset='0'%3e%3c/stop%3e%3cstop stop-color='rgba(67%2c 56%2c 202%2c 0.3)' offset='1'%3e%3c/stop%3e%3c/linearGradient%3e%3clinearGradient x1='0%25' y1='100%25' x2='0%25' y2='0%25' id='SvgjsLinearGradient1094'%3e%3cstop stop-color='rgba(233%2c 232%2c 249%2c 0)' offset='0'%3e%3c/stop%3e%3cstop stop-color='rgba(233%2c 232%2c 249%2c 0.3)' offset='1'%3e%3c/stop%3e%3c/linearGradient%3e%3clinearGradient x1='0%25' y1='0%25' x2='0%25' y2='100%25' id='SvgjsLinearGradient1095'%3e%3cstop stop-color='rgba(233%2c 232%2c 249%2c 0)' offset='0'%3e%3c/stop%3e%3cstop stop-color='rgba(233%2c 232%2c 249%2c 0.3)' offset='1'%3e%3c/stop%3e%3c/linearGradient%3e%3clinearGradient x1='0%25' y1='0%25' x2='0%25' y2='100%25' id='SvgjsLinearGradient1096'%3e%3cstop stop-color='rgba(67%2c 56%2c 202%2c 0)' offset='0'%3e%3c/stop%3e%3cstop stop-color='rgba(67%2c 56%2c 202%2c 0.3)' offset='1'%3e%3c/stop%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e\"; />\n            </div>\n            <div class=\"podlove-panel-banner\">\n              <div class=\"podlove-panel-banner-left\">\n                <div class=\"podlove-panel-banner-image\">\n                  <img src=\"<?php echo \\Podlove\\PLUGIN_URL.'/images/logo/podlove-publisher-icon-500.png'; ?>\" />\n                </div>\n              </div>\n              <div class=\"podlove-panel-banner-right\">\n                <h2 class=\"podlove-panel-banner-head\"><?php echo __('Welcome to Podlove', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n                <p class=\"podlove-panel-banner-text\">\n                  <?php echo __('Ready to share your voice with the world? Let\\'s start your podcasting journey! Explore our new Onboarding Assistant for a seamless setup. Choose between starting a new podcast or importing an existing one, and let\\'s get your stories out there!', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                </p>\n                <a id=\"podlove-panel-banner-button\" class=\"podlove-panel-banner-button\" href=\"<?php echo \\Podlove\\Modules\\Onboarding\\Settings\\OnboardingPage::get_page_link(); ?>\"><?php echo __('Get started', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    <script type=\"text/javascript\">\n      const podloveBanner = document.getElementById('podlove-panel-wrap');\n      function hiddenPodloveBanner() {\n        podloveBanner.classList.add('hidden');\n      }\n      const dismissLink = document.getElementById('podlove-panel-banner-dismiss');\n      if (dismissLink !== undefined && dismissLink !== null) {\n        dismissLink.addEventListener('click', function(){\n          fetch(ajaxurl + '?' + new URLSearchParams({\n              action: 'podlove-banner-hide',\n              _podlove_nonce: '<?php echo wp_create_nonce('podlove_onboarding'); ?>'\n            }),\n            {\n              method: 'GET'\n          }).then(response => {\n            if (response.ok) {\n              hiddenPodloveBanner();\n            }\n          })\n        });\n      }\n    </script>\n<?php\n    }\n\n    public function add_scripts_and_styles()\n    {\n        wp_register_style('podlove-onboarding-banner-style', $this->get_module_url().'/css/podlove-onboarding-banner.css');\n        wp_enqueue_style('podlove-onboarding-banner-style');\n    }\n\n    public function add_onboarding_menu()\n    {\n        new OnboardingPage(\\Podlove\\Podcast_Post_Type::SETTINGS_PAGE_HANDLE);\n    }\n\n    /**\n     * Onboarding options:\n     *    - hide banner\n     *    - type: start / import\n     *    - feedurl\n     */\n    public static function is_banner_hide()\n    {\n        $onboarding_options = self::get_options();\n        if (isset($onboarding_options['hide_banner'])) {\n            return $onboarding_options['hide_banner'];\n        }\n\n        return false;\n    }\n\n    public static function set_banner_hide($option)\n    {\n        $onboarding_options = self::get_options();\n        if (strtolower($option) == 'true') {\n            $onboarding_options['hide_banner'] = true;\n        } else {\n            if (isset($onboarding_options['hide_banner'])) {\n                unset($onboarding_options['hide_banner']);\n            }\n        }\n        self::update_options($onboarding_options);\n    }\n\n    /** PHP 8.1 change this to an enum */\n    public static function get_onboarding_type()\n    {\n        $onboarding_options = self::get_options();\n        if (isset($onboarding_options['type'])) {\n            return $onboarding_options['type'];\n        }\n    }\n\n    public static function set_onboarding_type($option)\n    {\n        $onboarding_options = self::get_options();\n        switch (strtolower($option)) {\n            case 'start':\n            case 'import':\n                $onboarding_options['type'] = $option;\n\n                break;\n\n            default:\n                if (isset($onboarding_options['type'])) {\n                    unset($onboarding_options['type']);\n                }\n\n                break;\n        }\n        self::update_options($onboarding_options);\n    }\n\n    public static function get_acknowlegde_option($user_id)\n    {\n        return get_user_meta($user_id, 'podlove_onboarding_acknowledge', true);\n    }\n\n    public static function set_acknowledge_option($user_id, $option)\n    {\n        update_user_meta($user_id, 'podlove_onboarding_acknowledge', $option);\n    }\n\n    /**\n     * Onboarding API init (add to admin-route).\n     */\n    public function api_init()\n    {\n        $api_onboarding = new WP_REST_PodloveOnboarding_Controller();\n        $api_onboarding->register_routes();\n    }\n\n    private static function get_options()\n    {\n        return get_option('podlove_modules_onboarding', []);\n    }\n\n    private static function update_options($onboarding_options)\n    {\n        update_option('podlove_modules_onboarding', $onboarding_options);\n    }\n}\n"
  },
  {
    "path": "lib/modules/onboarding/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Onboarding;\n\nuse Podlove\\Modules\\WordpressFileUpload\\Wordpress_File_Upload;\n\nclass WP_REST_PodloveOnboarding_Controller extends \\WP_REST_Controller\n{\n    /**\n     * Constructor.\n     */\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'onboarding';\n    }\n\n    /**\n     * Register the component routes.\n     */\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, $this->rest_base.'/setup', [\n            [\n                'methods' => \\WP_REST_SERVER::EDITABLE,\n                'callback' => [$this, 'update_items'],\n                'permission_callback' => [$this, 'update_permissions_check']\n            ]\n        ]);\n    }\n\n    public function update_items($request)\n    {\n        // activate File-Upload-Module and set default settings\n        if (!\\Podlove\\Modules\\Base::is_active('wordpress_file_upload')) {\n            \\Podlove\\Modules\\Base::activate('wordpress_file_upload');\n        }\n        $upload_modul = Wordpress_File_Upload::instance();\n        $upload_modul_dir = $upload_modul->get_module_option('upload_subdir');\n        if (empty($upload_modul_dir)) {\n            $upload_modul->update_module_option('upload_subdir', 'podlove-media');\n        }\n        // set upload loaction to emty\n        $settings = get_option('podlove_podcast');\n        $settings['media_file_base_uri'] = '';\n        update_option('podlove_podcast', $settings);\n        // activated contributor module\n        if (isset($request['contributor'])) {\n            $contributor = $request['contributor'];\n            if (!\\Podlove\\Modules\\Base::is_active('contributors') && $contributor) {\n                \\Podlove\\Modules\\Base::activate('contributors');\n            }\n        }\n        // activated transcript module\n        if (isset($request['transcript'])) {\n            $transcript = $request['transcript'];\n            if (!\\Podlove\\Modules\\Base::is_active('transcripts') && $transcript) {\n                \\Podlove\\Modules\\Base::activate('transcripts');\n            }\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "lib/modules/onboarding/settings/onboarding_page.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Onboarding\\Settings;\n\nuse Podlove\\Authentication;\nuse Podlove\\Modules\\Onboarding\\Onboarding;\n\nclass OnboardingPage\n{\n    private const DEFAULT_SERVICE_URL = 'https://services.podlove.org/onboarding';\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        OnboardingPage::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            'Onboarding',\n            // $menu_title\n            'Onboarding',\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_settings_onboarding_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        if (!defined('PODLOVE_ONBOARDING')) {\n            define('PODLOVE_ONBOARDING', self::DEFAULT_SERVICE_URL);\n        }\n    }\n\n    /**\n     * Get Service URL.\n     *\n     * If you want to host and use your own service, set the constant in your\n     * `wp-config.php`: `define('PODLOVE_ONBOARDING',\n     * 'https://self-hosted-services.example.com/onboarding');`\n     */\n    public static function get_service_url()\n    {\n        if (is_string(PODLOVE_ONBOARDING)) {\n            return PODLOVE_ONBOARDING;\n        }\n\n        return null;\n    }\n\n    public static function get_page_link()\n    {\n        return admin_url('admin.php?page=podlove_settings_onboarding_handle');\n    }\n\n    public function page()\n    {\n        $onboardingInclude = self::get_service_url();\n\n        if (!$onboardingInclude) {\n            return;\n        }\n\n        $authentication = Authentication::application_password();\n\n        $site = urlencode(rtrim(get_site_url(), '/'));\n        $rest_url = urlencode(rtrim(get_rest_url(), '/'));\n        $user = $authentication['name'];\n        $password = $authentication['password'];\n        $userLang = explode('_', get_locale())[0];\n\n        $nonce = wp_create_nonce('podlove_onboarding_acknowledge');\n        $wp_user_id = get_current_user_id();\n        $acknowledgeOption = Onboarding::get_acknowlegde_option($wp_user_id);\n\n        $iframeSrc = \"{$onboardingInclude}?site_url={$site}&rest_url={$rest_url}&user_login={$user}&password={$password}&lang={$userLang}\";\n        $acknowledgeHeadline = __('Onboarding Assistant 👋', 'podlove-podcasting-plugin-for-wordpress');\n        $acknowledgeDescription = __('To be able to offer you this service, we have to run the onboarding assistant on our external server. We have done everything in our power to make the service as privacy friendly as possible. We do not store any of your entered data, everything is saved in your browser 🤞. However, it is important to us that you are aware of this fact before you use the onboarding service.', 'podlove-podcasting-plugin-for-wordpress');\n        $acknowledgeButton = __('All right, I\\'ve got it', 'podlove-podcasting-plugin-for-wordpress');\n        $httpsWarningText = __('Warning: Your website is not configured to use https! This usually means that the authentication method the assistant uses is disabled by WordPress for security reasons. Please enable https before continuing.', 'podlove-podcasting-plugin-for-wordpress');\n        $applicationPasswordWarningText = __('Warning: Application passwords are not available. Maybe a security plugin is blocking them.', 'podlove-podcasting-plugin-for-wordpress');\n\n        $httpsWarning = !wp_is_using_https() ? <<<EOD\n          <p class=\"onboarding-warning\">⚠️ {$httpsWarningText}</p>\n        EOD : '';\n\n        $applicationPasswordWarning = !wp_is_application_passwords_available_for_user(wp_get_current_user()) ? <<<EOD\n          <p class=\"onboarding-warning\">⚠️ {$applicationPasswordWarningText}</p>\n        EOD : '';\n\n        // don't skip intro page if there are warnings\n        if ($httpsWarning || $applicationPasswordWarning) {\n            $acknowledgeOption = false;\n        }\n\n        echo <<<EOD\n      <iframe id=\"onboarding-assistant\" class=\"hidden\"></iframe>\n      <div id=\"onboarding-acknowledge\">\n        <div id=\"onboarding-acknowledge-message\">\n          <h1 class=\"onboarding-headline\">{$acknowledgeHeadline}</h1>\n          <p class=\"onboarding-description\">{$acknowledgeDescription}</p>\n          {$httpsWarning}\n          {$applicationPasswordWarning}\n          <button id=\"acknowledge-button\" class=\"onboarding-button\">{$acknowledgeButton}</button>\n        </div>\n      </div>\n\n      <script type=\"module\">\n        const acknowledgeHint = document.getElementById(\"onboarding-acknowledge\");\n        const acknowledgeButton = document.getElementById(\"acknowledge-button\");\n        const onboardingAssistant = document.getElementById(\"onboarding-assistant\");\n        const onboardingAcknowledged = \"{$acknowledgeOption}\";\n\n        function loadService() {\n          onboardingAssistant.contentWindow.location.href = \"{$iframeSrc}\";\n          onboardingAssistant.classList.remove(\"hidden\");\n          acknowledgeHint.classList.add(\"hidden\");\n        }\n\n        if (onboardingAcknowledged) {\n          loadService();\n        }\n\n        acknowledgeButton.addEventListener(\"click\", function() {\n          fetch(ajaxurl + '?' + new URLSearchParams({\n              action: 'podlove-onboarding-acknowledge',\n              _podlove_nonce: \"{$nonce}\"\n            }),\n            {\n              method: 'GET'\n          }).then(response => {\n            if (response.ok) {\n              loadService();\n            }\n          })\n        });\n      </script>\n\n      <style>\n        #onboarding-assistant.hidden, #onboarding-acknowledge.hidden {\n          display: none;\n        }\n\n        #onboarding-assistant, #onboarding-acknowledge {\n          width: 100%;\n          height: calc(100vh - 32px);\n          position: absolute;\n          top: 0;\n        }\n\n        #onboarding-acknowledge {\n          padding-top: 50px;\n          background: rgb(243 244 246);\n          font-size: 0.875rem;\n          line-height: 1.25rem;\n        }\n\n        #onboarding-acknowledge-message {\n          background: white;\n          padding: 20px;\n          box-sizing: border-box;\n          max-width: 700px;\n          margin-left: auto;\n          margin-right: auto;\n          border-radius: 0.5rem;\n          --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\n          --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);\n          box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n        }\n\n        .onboarding-headline {\n          font-size: 1rem;\n          line-height: 1.5rem;\n          margin: 0;\n          padding: 0;\n        }\n\n\n        .onboarding-description {\n          color: rgb(107 114 128);\n        }\n\n        .onboarding-warning {\n          color: rgb(107 114 128);\n          font-weight: bold;\n        }\n\n        .update-message {\n          display: none;\n        }\n\n        .onboarding-button {\n          color: white;\n          padding: 0.5rem 0.75rem;\n          font-weight: 500;\n          border-color: transparent;\n          background-color: rgb(79 70 229);\n          border-width: 1px;\n          border-radius: 0.375rem;\n          cursor: pointer;\n        }\n\n        #wpbody {\n          height: 100%;\n        }\n\n        #wpcontent {\n          padding-left: 0;\n          padding-bottom: 0;\n          height: 100%;\n        }\n\n        #wpfooter {\n          display: none;\n        }\n      </style>\n    EOD;\n    }\n}\n"
  },
  {
    "path": "lib/modules/open_graph/open_graph.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\OpenGraph;\n\nuse Podlove\\DomDocumentFragment;\nuse Podlove\\Model;\n\nclass Open_Graph extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Open Graph Integration';\n    protected $module_description = 'Adds Open Graph metadata to episodes. Useful for third party services.';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        add_action('wp', [$this, 'register_hooks']);\n    }\n\n    /**\n     * Register hooks on episode pages only.\n     */\n    public function register_hooks()\n    {\n        // wpseo creates its own tags\n        if (is_plugin_active('wordpress-seo/wp-seo.php')) {\n            return;\n        }\n\n        // all in one seo creates its own tags\n        if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')) {\n            return;\n        }\n\n        // wpseo creates its own tags\n        if (defined('WPSEODE_BASE')) {\n            return;\n        }\n\n        if (!is_single()) {\n            return;\n        }\n\n        if ('podcast' !== get_post_type()) {\n            return;\n        }\n\n        add_filter('language_attributes', function ($output = '') {\n            return $output.' prefix=\"og: http://ogp.me/ns#\"';\n        });\n\n        // as recommended in http://jetpack.me/2013/05/03/remove-open-graph-meta-tags/\n        // @fixme Generate conflicts for known conflicting plugins.\n        //        Get inspired by Jetpack's list class.jetpack.php \"open_graph_conflicting_plugins\"\n        add_filter('jetpack_enable_open_graph', '__return_false');\n\n        add_action('wp_head', [$this, 'the_open_graph_metadata']);\n    }\n\n    public function the_open_graph_metadata()\n    {\n        $cache_key = 'opgv2'.get_the_ID().get_permalink();\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        echo $cache->cache_for($cache_key, function () {\n            return (string) \\Podlove\\Modules\\OpenGraph\\Open_Graph::get_open_graph_metadata();\n        });\n    }\n\n    /**\n     * Insert HTML meta tags into site head.\n     *\n     * @todo  caching\n     * @todo  let user choose what's in og:description: subtitle, excerpt, ...\n     * @todo  handle multiple releases per episode\n     */\n    public static function get_open_graph_metadata()\n    {\n        $post_id = get_the_ID();\n        if (!$post_id) {\n            return;\n        }\n\n        $post = get_post($post_id);\n\n        $episode = \\Podlove\\Model\\Episode::find_one_by_post_id($post_id);\n        if (!$episode) {\n            return;\n        }\n\n        $podcast = Model\\Podcast::get();\n        $cover_art_url = $episode->cover_art_with_fallback()->url();\n\n        // determine featured image (thumbnail)\n        $thumbnail = null;\n        if (has_post_thumbnail()) {\n            $post_thumbnail_id = get_post_thumbnail_id($post_id);\n            $thumbnailInfo = wp_get_attachment_image_src($post_thumbnail_id);\n            if (is_array($thumbnailInfo)) {\n                list($thumbnail, $width, $height) = $thumbnailInfo;\n            }\n        }\n\n        $description = null;\n        if ($episode->summary && $episode->subtitle) {\n            $description = $episode->subtitle.\"\\n\".$episode->summary;\n        } elseif ($episode->summary) {\n            $description = $episode->summary;\n        } elseif ($episode->subtitle) {\n            $description = $episode->subtitle;\n        }\n\n        // define meta tags\n        $data = [\n            [\n                'property' => 'og:type',\n                'content' => 'website',\n            ],\n            [\n                'property' => 'og:site_name',\n                'content' => ($podcast->title) ? $podcast->title : get_the_title(),\n            ],\n            [\n                'property' => 'og:title',\n                'content' => $post->post_title,\n            ],\n            [\n                'property' => 'og:url',\n                'content' => get_permalink(),\n            ],\n        ];\n\n        if ($description) {\n            $data[] = [\n                'property' => 'og:description',\n                'content' => $description,\n            ];\n        }\n\n        $image_url = $cover_art_url ?? $thumbnail;\n\n        if ($image_url) {\n            $data[] = apply_filters('podlove_ogp_image_data', [\n                'property' => 'og:image',\n                'content' => $image_url,\n            ]);\n        }\n\n        foreach ($episode->media_files() as $media_file) {\n            $asset = $media_file->episode_asset();\n            if ($asset->downloadable && $file_type = $asset->file_type()) {\n                $mime_type = $file_type->mime_type;\n                if (stripos($mime_type, 'audio') !== false) {\n                    $data[] = ['property' => 'og:audio', 'content' => $media_file->get_public_file_url('opengraph', 'episode')];\n                    $data[] = ['property' => 'og:audio:type', 'content' => $mime_type];\n                }\n            }\n        }\n\n        // print meta tags\n        $dom = new DomDocumentFragment();\n\n        foreach ($data as $meta_element) {\n            $element = $dom->createElement('meta');\n            foreach ($meta_element as $attribute => $value) {\n                $element->setAttribute($attribute, $value);\n            }\n            $dom->appendChild($element);\n        }\n\n        return $dom;\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nuse Podlove\\Http;\nuse Podlove\\Model\\Podcast;\n\nclass API\n{\n    private $module;\n    private $token;\n\n    public function __construct($module, $token)\n    {\n        $this->module = $module;\n        $this->token = $token;\n    }\n\n    public function getToken()\n    {\n        return $this->token;\n    }\n\n    public function get_me()\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/me', $this->params());\n\n        return $this->handle_json_response($curl);\n    }\n\n    public function get_account_id()\n    {\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n\n        return $cache->cache_for('plus_account_id', function () {\n            $user = $this->get_me();\n\n            if (!$user) {\n                return;\n            }\n\n            return $user->account_id;\n        }, 60);\n    }\n\n    public function list_feeds()\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/feeds', $this->params());\n\n        return $this->handle_json_response($curl);\n    }\n\n    public function push_feeds($feeds)\n    {\n        $payload = wp_json_encode(['feeds' => $feeds]);\n\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/feeds', $this->params([\n            'method' => 'POST',\n            'body' => $payload,\n        ]));\n\n        do_action('podlove_plus_api_push_feeds');\n\n        return $curl->get_response();\n    }\n\n    public function get_proxy_url($origin_url)\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/feeds/proxy_url?url='.urlencode($origin_url), $this->params());\n\n        $response = $this->handle_json_response($curl);\n        if ($response) {\n            return $response->url ?? false;\n        }\n\n        return false;\n    }\n\n    public function create_image_preset($template_name, $modifications = [])\n    {\n        $payload = wp_json_encode(['template' => $template_name, 'modifications' => $modifications]);\n\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/image/preset', $this->params([\n            'method' => 'POST',\n            'body' => $payload,\n        ]));\n\n        do_action('podlove_plus_api_create_image_preset');\n\n        return $curl->get_response();\n    }\n\n    public function create_file_upload($filename)\n    {\n        $filename = $this->sanitize_filename($filename);\n\n        $query = http_build_query([\n            'filename' => $filename,\n            'podcast_guid' => (string) Podcast::get()->guid\n        ]);\n\n        $curl = new Http\\Curl();\n        $curl->request(\n            $this->module::base_url().'/api/rest/v1/files/upload/new?'.$query,\n            $this->params(['method' => 'POST'])\n        );\n\n        $response = $this->handle_json_response($curl);\n        if ($response) {\n            return $response->url ?? false;\n        }\n\n        return false;\n    }\n\n    public function check_file_exists($filename)\n    {\n        $filename = $this->sanitize_filename($filename);\n\n        $query = http_build_query([\n            'filename' => $filename,\n            'podcast_guid' => (string) Podcast::get()->guid\n        ]);\n\n        $curl = new Http\\Curl();\n        $curl->request(\n            $this->module::base_url().'/api/rest/v1/files/upload/exists?'.$query,\n            $this->params(['method' => 'GET'])\n        );\n\n        $response = $this->handle_json_response($curl);\n        if ($response) {\n            return $response->exists ?? false;\n        }\n\n        return false;\n    }\n\n    public function complete_file_upload($filename)\n    {\n        $filename = $this->sanitize_filename($filename);\n\n        $query = http_build_query([\n            'filename' => $filename,\n            'podcast_guid' => (string) Podcast::get()->guid\n        ]);\n\n        $curl = new Http\\Curl();\n        $curl->request(\n            $this->module::base_url().'/api/rest/v1/files/upload/complete?'.$query,\n            $this->params(['method' => 'POST'])\n        );\n\n        $response = $this->handle_json_response($curl);\n        if ($response) {\n            return $response->file ?? false;\n        }\n\n        return false;\n    }\n\n    public function migrate_file($filename, $file_url, $prevent_double_uploads = true)\n    {\n        $filename = $this->sanitize_filename($filename);\n\n        // prevent double uploads\n        if ($prevent_double_uploads && $this->check_file_exists($filename)) {\n            return true;\n        }\n\n        $presigned_upload_url = $this->create_file_upload($filename);\n\n        if (!$presigned_upload_url) {\n            return false;\n        }\n\n        if (!$this->do_upload($presigned_upload_url, $file_url, $filename)) {\n            return false;\n        }\n\n        return $this->complete_file_upload($filename);\n    }\n\n    /**\n     * Migrate an Auphonic file to PLUS storage.\n     *\n     * This method is a wrapper around migrate_file specifically for Auphonic files.\n     * It provides additional error handling and logging for the Auphonic integration.\n     *\n     * @param string $auphonic_url The download URL from Auphonic\n     * @param string $filename     The filename to use in PLUS storage\n     *\n     * @return bool True on success, false on failure\n     */\n    public function migrate_auphonic_file($auphonic_url, $filename)\n    {\n        $filename = $this->sanitize_filename($filename);\n\n        \\Podlove\\Log::get()->addInfo(\n            'Starting Auphonic file migration to PLUS storage.',\n            ['filename' => $filename, 'source_url' => $auphonic_url]\n        );\n\n        try {\n            $result = $this->migrate_file($filename, $auphonic_url, false);\n\n            if ($result) {\n                \\Podlove\\Log::get()->addInfo(\n                    'Auphonic file migration to PLUS storage successful.',\n                    ['filename' => $filename]\n                );\n            } else {\n                \\Podlove\\Log::get()->addError(\n                    'Auphonic file migration to PLUS storage failed.',\n                    ['filename' => $filename, 'source_url' => $auphonic_url]\n                );\n            }\n\n            return $result;\n        } catch (\\Exception $e) {\n            \\Podlove\\Log::get()->addError(\n                'Auphonic file migration to PLUS storage failed with exception.',\n                ['filename' => $filename, 'source_url' => $auphonic_url, 'error' => $e->getMessage()]\n            );\n\n            return false;\n        }\n    }\n\n    /**\n     * List all podcasts for the connected account in PLUS.\n     */\n    public function list_podcasts()\n    {\n        // Without a token, the PLUS API will return 401. Treat as no podcasts.\n        if (trim((string) $this->token) === '') {\n            return [];\n        }\n\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/podcasts', $this->params());\n\n        $response = $this->handle_json_response($curl);\n\n        return is_array($response) ? $response : [];\n    }\n\n    /**\n     * Update podcast title in PLUS.\n     *\n     * This function will create a podcast if it doesn't exist yet.\n     */\n    public function upsert_podcast_title(string $guid, string $title)\n    {\n        $podcast = $this->get_podcast_by_guid($guid);\n\n        if ($podcast) {\n            return $this->update_podcast($podcast->id, ['title' => $title]);\n        }\n\n        return $this->create_podcast($guid, ['title' => $title]);\n    }\n\n    /**\n     * Get PLUS podcast by guid.\n     */\n    public function get_podcast_by_guid(string $guid)\n    {\n        $podcasts = $this->list_podcasts();\n        if ($podcasts === []) {\n            return false;\n        }\n\n        $matching_podcast = array_filter($podcasts, function ($podcast) use ($guid) {\n            return $podcast->guid === $guid;\n        });\n\n        return array_values($matching_podcast)[0] ?? false;\n    }\n\n    /**\n     * Get PLUS podcast by id.\n     */\n    public function get_podcast(int $podcast_id)\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/podcasts/'.$podcast_id, $this->params());\n\n        return $this->handle_json_response($curl);\n    }\n\n    public function update_podcast(int $podcast_id, array $data)\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/podcasts/'.$podcast_id, $this->params([\n            'method' => 'PUT',\n            'body' => wp_json_encode(['podcast' => $data]),\n        ]));\n\n        return $this->handle_json_response($curl);\n    }\n\n    /**\n     * Create a podcast in PLUS.\n     *\n     * Currently only supports the required fields: `guid` and `title`.\n     */\n    public function create_podcast(string $guid, array $data)\n    {\n        $curl = new Http\\Curl();\n        $curl->request($this->module::base_url().'/api/rest/v1/podcasts', $this->params([\n            'method' => 'POST',\n            'body' => wp_json_encode(['podcast' => ['guid' => $guid, 'title' => $data['title']]]),\n        ]));\n\n        return $this->handle_json_response($curl);\n    }\n\n    /**\n     * Sets a flag to indicate that the file migration has been completed.\n     *\n     * @return array Response with success status\n     */\n    public function set_migration_complete()\n    {\n        update_option('podlove_plus_migration_completed', true);\n\n        return ['success' => true];\n    }\n\n    /**\n     * Checks if the migration has been completed.\n     *\n     * @return bool True if migration has been completed, false otherwise\n     */\n    public function is_migration_complete()\n    {\n        return (bool) get_option('podlove_plus_migration_completed');\n    }\n\n    private function do_upload($target_url, $origin_url, $filename)\n    {\n        $filename = $this->sanitize_filename($filename);\n        $temp_file = \\get_temp_dir().$filename;\n\n        try {\n            // Download to temporary file with streaming\n            $response = wp_remote_get($origin_url, [\n                'timeout' => 300,\n                'stream' => true,\n                'filename' => $temp_file\n            ]);\n\n            if (is_wp_error($response)) {\n                error_log('Download failed: '.$response->get_error_message());\n                @unlink($temp_file);\n\n                return false;\n            }\n\n            // Get file size and content type\n            $file_size = filesize($temp_file);\n            $content_type = wp_remote_retrieve_header($response, 'content-type');\n            if (empty($content_type)) {\n                $content_type = 'application/octet-stream';\n            }\n\n            // Open the temporary file\n            $file_handle = fopen($temp_file, 'r');\n            if (!$file_handle) {\n                error_log(\"Cannot open temporary file for reading: {$temp_file}\");\n                @unlink($temp_file);\n\n                return false;\n            }\n\n            // Initialize cURL for upload\n            $ch = curl_init();\n            curl_setopt($ch, CURLOPT_URL, $target_url);\n            curl_setopt($ch, CURLOPT_PUT, true);\n            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);\n            curl_setopt($ch, CURLOPT_INFILE, $file_handle);\n            curl_setopt($ch, CURLOPT_INFILESIZE, $file_size);\n            curl_setopt($ch, CURLOPT_HTTPHEADER, [\n                'Content-Type: '.$content_type,\n                'Content-Length: '.$file_size\n            ]);\n\n            // Execute upload\n            $upload_response = curl_exec($ch);\n            $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n            $curl_error = curl_error($ch);\n\n            curl_close($ch);\n            fclose($file_handle);\n            @unlink($temp_file);\n\n            if ($curl_error) {\n                error_log(\"cURL error during upload: {$curl_error}\");\n            }\n\n            if ($http_code < 200 || $http_code >= 300) {\n                error_log(\"Upload failed with HTTP code {$http_code}: {$upload_response}\");\n\n                return false;\n            }\n\n            return true;\n        } catch (\\Exception $e) {\n            error_log('Exception during file migration: '.$e->getMessage());\n\n            // Cleanup resources\n            if (isset($file_handle) && is_resource($file_handle)) {\n                fclose($file_handle);\n            }\n            if (isset($ch) && is_resource($ch)) {\n                curl_close($ch);\n            }\n            @unlink($temp_file);\n\n            return false;\n        }\n    }\n\n    /**\n     * Handles common JSON response processing.\n     *\n     * @param Http\\Curl $curl The curl object with the executed request\n     *\n     * @return mixed Decoded JSON object or false on failure\n     */\n    private function handle_json_response($curl)\n    {\n        $response = $curl->get_response();\n\n        if ($curl->isSuccessful()) {\n            return json_decode($response['body']) ?? false;\n        }\n\n        return false;\n    }\n\n    private function params($params = [])\n    {\n        return array_merge([\n            'headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->token,\n            ],\n        ], $params);\n    }\n\n    /**\n     * Sanitizes filenames by replacing slashes with dashes to prevent path traversal issues.\n     *\n     * @param string $filename The filename to sanitize\n     *\n     * @return string The sanitized filename\n     */\n    private function sanitize_filename($filename)\n    {\n        return str_replace('/', '-', $filename);\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/banner.html.php",
    "content": "<?php\n/**\n * PLUS Banner template file.\n *\n * @param string $title       Banner title\n * @param string $content     Banner content HTML\n * @param string $button_text Button text\n * @param string $button_url  Button URL\n * @param string $logo_text   Logo text\n * @param bool   $external    Whether the link should open in a new tab\n */\n\n// Prevent direct file access\nif (!defined('ABSPATH')) {\n    exit;\n}\n?>\n<div class=\"plus-banner\">\n  <h3><?php echo esc_html($title); ?></h3>\n  <div class=\"plus-banner-content\">\n      <?php echo wp_kses_post($content); ?>\n  </div>\n  <div class=\"plus-banner-footer\">\n    <a href=\"<?php echo esc_url($button_url); ?>\" class=\"btn\"<?php echo $external ? ' target=\"_blank\" rel=\"noopener\"' : ''; ?>><?php echo esc_html($button_text); ?></a>\n    <div class=\"corner-logo\">\n      <svg class=\"logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 99.32 160.81\" style=\"width: 18px; height: 24px;\">\n        <path fill=\"#ffffff\" d=\"M78.119 9c6.728 0 12.201 5.474 12.201 12.202V139.61c0 6.728-5.474 12.201-12.201 12.201H21.2c-6.727 0-12.2-5.473-12.2-12.201V21.202C9 14.474 14.473 9 21.2 9zm0-9H21.2C9.493 0 0 9.493 0 21.202V139.61c0 11.708 9.493 21.201 21.2 21.201h56.919c11.71 0 21.201-9.492 21.201-21.201V21.202C99.32 9.493 89.829 0 78.119 0z\"/>\n        <path fill=\"#ffffff\" d=\"M49.576 90.412c12.742 0 23.069 10.327 23.069 23.068 0 12.74-10.327 23.069-23.069 23.069-12.738 0-23.067-10.329-23.067-23.069 0-12.741 10.329-23.068 23.067-23.068m0-9c-17.682 0-32.067 14.386-32.067 32.068 0 17.683 14.385 32.069 32.067 32.069 17.683 0 32.069-14.386 32.069-32.069.001-17.682-14.386-32.068-32.069-32.068z\"/>\n        <g clip-rule=\"evenodd\">\n          <path fill=\"none\" stroke=\"#ffffff\" stroke-miterlimit=\"10\" stroke-width=\"9\" d=\"M72.895 46.223l-23.57 23.583L25.758 46.22c-2.649-2.7-4.285-6.399-4.285-10.481 0-8.267 6.702-14.968 14.968-14.968 5.485 0 10.278 2.949 12.885 7.347 2.606-4.398 7.401-7.347 12.884-7.347 8.268 0 14.97 6.701 14.97 14.968 0 4.082-1.636 7.783-4.285 10.484z\"/>\n          <path fill=\"#ffffff\" fill-rule=\"evenodd\" d=\"M49.577 105.223c4.561 0 8.26 3.698 8.26 8.257 0 4.562-3.699 8.258-8.26 8.258-4.56 0-8.257-3.696-8.257-8.258 0-4.559 3.697-8.257 8.257-8.257z\"/>\n        </g>\n      </svg>\n      <div class=\"logo-text\"><?php echo esc_html($logo_text); ?></div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "lib/modules/plus/banner.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\n/**\n * Banner.\n *\n * Modular banner component that can be reused to display different banners\n * with the same visual style but varying content.\n */\nclass Banner\n{\n    private $title;\n    private $content;\n    private $button_text;\n    private $button_url;\n    private $logo_text;\n    private $external;\n\n    /**\n     * Constructor.\n     *\n     * @param string $title       Banner title\n     * @param string $content     Banner content HTML\n     * @param string $button_text Button text\n     * @param string $button_url  Button URL\n     * @param string $logo_text   Logo text\n     * @param bool   $external    Whether the link should open in a new tab\n     */\n    public function __construct($title, $content, $button_text, $button_url, $logo_text = 'A Publisher PLUS Feature', $external = false)\n    {\n        $this->title = $title;\n        $this->content = $content;\n        $this->button_text = $button_text;\n        $this->button_url = $button_url;\n        $this->logo_text = $logo_text;\n        $this->external = $external;\n    }\n\n    /**\n     * Render the banner.\n     */\n    public function render()\n    {\n        extract([\n            'title' => $this->title,\n            'content' => $this->content,\n            'button_text' => $this->button_text,\n            'button_url' => $this->button_url,\n            'logo_text' => $this->logo_text,\n            'external' => $this->external,\n        ]);\n\n        include __DIR__.'/banner.html.php';\n    }\n\n    /**\n     * Create and render a feed proxy banner.\n     */\n    public static function feed_proxy()\n    {\n        $content = '<p>'\n        .__('High-traffic RSS feeds can slow down your podcast hosting. <strong>Reliable Feed Delivery</strong> keeps your feed fast and available by offloading traffic to our optimized servers, even during traffic spikes. Stop worrying about server load and focus on creating great content.', 'podlove-podcasting-plugin-for-wordpress')\n        .'</p>';\n\n        $banner = new self(\n            __('Optimize Your Podcast\\'s Performance', 'podlove-podcasting-plugin-for-wordpress'),\n            $content,\n            __('Enable Reliable Feed Delivery', 'podlove-podcasting-plugin-for-wordpress'),\n            admin_url('admin.php?page=publisher_plus_settings')\n        );\n\n        $banner->render();\n    }\n\n    /**\n     * Create and render a file storage banner.\n     */\n    public static function file_storage()\n    {\n        $content = '<p>'\n        .__('Store your podcast files in fast and reliable cloud hosting built for podcast delivery. Avoid the storage and performance limits of serving files directly from WordPress as your show grows.', 'podlove-podcasting-plugin-for-wordpress')\n        .'</p>';\n\n        $banner = new self(\n            __('Podcast File Hosting', 'podlove-podcasting-plugin-for-wordpress'),\n            $content,\n            __('Enable Podcast File Hosting', 'podlove-podcasting-plugin-for-wordpress'),\n            admin_url('admin.php?page=publisher_plus_settings')\n        );\n\n        $banner->render();\n    }\n\n    public static function plus_main()\n    {\n        $content = '<p><strong>Tired of fiddling with FTP or overloading your WordPress host when you release an episode?</strong><br>\n With <strong>Publisher PLUS</strong>, your podcast files are stored in fast, secure cloud storage—no setup required.</p>\n\n<ul class=\"banner-feature-list\">\n  <li>Simple uploads</li>\n  <li>Reliable delivery</li>\n  <li>Optimized for podcasting</li>\n</ul>\n\n <p><strong>Start your PLUS upgrade today.</strong></p>';\n\n        $banner = new self(\n            __('Introducing Publisher PLUS: File Hosting Built for Podcasters', 'podlove-podcasting-plugin-for-wordpress'),\n            $content,\n            __('Get Publisher PLUS &#10140;', 'podlove-podcasting-plugin-for-wordpress'),\n            'https://plus.podlove.org/pricing',\n            'A Publisher PLUS Feature',\n            true\n        );\n\n        $banner->render();\n    }\n\n    /**\n     * Create and render a banner for authenticated PLUS users.\n     */\n    public static function plus_authenticated()\n    {\n        $content = 'Manage your account  and access advanced features from your dashboard.</p>';\n\n        $banner = new self(\n            __('Manage Your Publisher PLUS Account', 'podlove-podcasting-plugin-for-wordpress'),\n            $content,\n            __('Go to PLUS Dashboard &#10140;', 'podlove-podcasting-plugin-for-wordpress'),\n            'https://plus.podlove.org/dashboard',\n            'Publisher PLUS',\n            true\n        );\n\n        $banner->render();\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/early_file_hosting_banner.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass EarlyFileHostingBanner\n{\n    public const BANNER_NAME = PromotionCoordinator::EARLY_FILE_HOSTING_BANNER;\n    public const DISMISS_NONCE_ACTION = 'podlove_plus_early_file_hosting_banner_dismiss';\n\n    private $coordinator;\n\n    public function __construct(PromotionCoordinator $coordinator)\n    {\n        $this->coordinator = $coordinator;\n    }\n\n    public function init()\n    {\n        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);\n        add_action('admin_init', [$this, 'maybe_handle_dismiss']);\n        add_action('admin_notices', [$this, 'render']);\n        add_action('wp_ajax_podlove_plus_early_file_hosting_banner_dismiss', [$this, 'ajax_dismiss']);\n    }\n\n    public function enqueue_assets()\n    {\n        if (!$this->should_render()) {\n            return;\n        }\n\n        $version = \\Podlove\\get_plugin_header('Version');\n        wp_enqueue_style('podlove-admin', \\Podlove\\PLUGIN_URL.'/css/admin.css', [], $version);\n        wp_enqueue_style('podlove-admin-font', \\Podlove\\PLUGIN_URL.'/css/admin-font.css', [], $version);\n    }\n\n    public function render()\n    {\n        if (!$this->should_render()) {\n            return;\n        }\n\n        $dismiss_url = wp_nonce_url(add_query_arg('podlove_dismiss_plus_early_file_hosting_banner', '1'), self::DISMISS_NONCE_ACTION);\n        ?>\n        <div id=\"podlove-plus-early-file-hosting-banner-wrap\" style=\"margin: 20px 20px 0 2px;\">\n            <div id=\"podlove-plus-early-file-hosting-banner\" class=\"plus-banner\" style=\"max-width: none;\">\n                <a\n                    class=\"podlove-plus-early-file-hosting-banner-dismiss\"\n                    href=\"<?php echo esc_url($dismiss_url); ?>\"\n                    aria-label=\"<?php esc_attr_e('Dismiss', 'podlove-podcasting-plugin-for-wordpress'); ?>\"\n                    style=\"position: absolute; top: 12px; right: 14px; color: rgba(255, 255, 255, 0.85); text-decoration: none; font-size: 22px; line-height: 1;\"\n                >&times;</a>\n                <h3><?php esc_html_e('Host your podcast files on infrastructure built for delivery', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n                <div class=\"plus-banner-content\">\n                    <p><?php esc_html_e('Publisher PLUS File Storage helps you keep uploads simple and your podcast files reliable from the start, without depending on your WordPress host for media delivery.', 'podlove-podcasting-plugin-for-wordpress'); ?></p>\n                </div>\n                <div class=\"plus-banner-footer\">\n                    <a href=\"<?php echo esc_url(admin_url('admin.php?page=publisher_plus_settings')); ?>\" class=\"btn\">\n                        <?php esc_html_e('Explore Podcast File Hosting', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                    </a>\n                    <div class=\"corner-logo\">\n                        <svg class=\"logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 99.32 160.81\" style=\"width: 18px; height: 24px;\">\n                            <path fill=\"#ffffff\" d=\"M78.119 9c6.728 0 12.201 5.474 12.201 12.202V139.61c0 6.728-5.474 12.201-12.201 12.201H21.2c-6.727 0-12.2-5.473-12.2-12.201V21.202C9 14.474 14.473 9 21.2 9zm0-9H21.2C9.493 0 0 9.493 0 21.202V139.61c0 11.708 9.493 21.201 21.2 21.201h56.919c11.71 0 21.201-9.492 21.201-21.201V21.202C99.32 9.493 89.829 0 78.119 0z\"/>\n                            <path fill=\"#ffffff\" d=\"M49.576 90.412c12.742 0 23.069 10.327 23.069 23.068 0 12.74-10.327 23.069-23.069 23.069-12.738 0-23.067-10.329-23.067-23.069 0-12.741 10.329-23.068 23.067-23.068m0-9c-17.682 0-32.067 14.386-32.067 32.068 0 17.683 14.385 32.069 32.067 32.069 17.683 0 32.069-14.386 32.069-32.069.001-17.682-14.386-32.068-32.069-32.068z\"/>\n                            <g clip-rule=\"evenodd\">\n                                <path fill=\"none\" stroke=\"#ffffff\" stroke-miterlimit=\"10\" stroke-width=\"9\" d=\"M72.895 46.223l-23.57 23.583L25.758 46.22c-2.649-2.7-4.285-6.399-4.285-10.481 0-8.267 6.702-14.968 14.968-14.968 5.485 0 10.278 2.949 12.885 7.347 2.606-4.398 7.401-7.347 12.884-7.347 8.268 0 14.97 6.701 14.97 14.968 0 4.082-1.636 7.783-4.285 10.484z\"/>\n                                <path fill=\"#ffffff\" fill-rule=\"evenodd\" d=\"M49.577 105.223c4.561 0 8.26 3.698 8.26 8.257 0 4.562-3.699 8.258-8.26 8.258-4.56 0-8.257-3.696-8.257-8.258 0-4.559 3.697-8.257 8.257-8.257z\"/>\n                            </g>\n                        </svg>\n                        <div class=\"logo-text\"><?php esc_html_e('Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'); ?></div>\n                    </div>\n                </div>\n            </div>\n        </div>\n        <script>\n            (function() {\n                document.addEventListener('click', function(event) {\n                    const dismissButton = event.target.closest('#podlove-plus-early-file-hosting-banner .podlove-plus-early-file-hosting-banner-dismiss');\n                    if (!dismissButton) {\n                        return;\n                    }\n\n                    const data = new window.FormData();\n                    data.append('action', 'podlove_plus_early_file_hosting_banner_dismiss');\n                    data.append('_ajax_nonce', '<?php echo esc_js(wp_create_nonce(self::DISMISS_NONCE_ACTION)); ?>');\n\n                    fetch(ajaxurl, {\n                        method: 'POST',\n                        body: data,\n                        credentials: 'same-origin'\n                    });\n                });\n            }());\n        </script>\n        <?php\n    }\n\n    public function maybe_handle_dismiss()\n    {\n        if (!isset($_GET['podlove_dismiss_plus_early_file_hosting_banner'])) {\n            return;\n        }\n\n        if (!current_user_can('manage_options')) {\n            return;\n        }\n\n        check_admin_referer(self::DISMISS_NONCE_ACTION);\n        $this->coordinator->dismiss(self::BANNER_NAME);\n\n        wp_safe_redirect(remove_query_arg(['podlove_dismiss_plus_early_file_hosting_banner', '_wpnonce']));\n        exit;\n    }\n\n    public function ajax_dismiss()\n    {\n        check_ajax_referer(self::DISMISS_NONCE_ACTION);\n\n        if (!current_user_can('manage_options')) {\n            wp_send_json_error([], 403);\n        }\n\n        $this->coordinator->dismiss(self::BANNER_NAME);\n        wp_send_json_success();\n    }\n\n    private function should_render(): bool\n    {\n        return $this->coordinator->should_render(self::BANNER_NAME);\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/feed_proxy.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass FeedProxy\n{\n    private $module;\n    private $api;\n\n    public function __construct($module, $api)\n    {\n        $this->module = $module;\n        $this->api = $api;\n    }\n\n    public static function is_enabled()\n    {\n        if (!\\Podlove\\Modules\\Base::is_active('plus')) {\n            return false;\n        }\n\n        return (bool) \\Podlove\\Model\\Podcast::get()->plus_enable_proxy;\n    }\n\n    public function init()\n    {\n        add_action('podlove_plus_api_push_feeds', [$this, 'refresh_feed_proxy_cache']);\n    }\n\n    public function refresh_feed_proxy_cache()\n    {\n        $feeds = $this->api->list_feeds();\n        update_option('podlove_proxy_feeds', $feeds);\n    }\n\n    public static function get_proxy_url($origin_url)\n    {\n        $feeds = get_option('podlove_proxy_feeds');\n\n        if (!is_array($feeds)) {\n            return null;\n        }\n\n        return array_reduce($feeds, function ($agg, $item) use ($origin_url) {\n            if ($agg !== null) {\n                return $agg;\n            }\n\n            if (self::normalize_url($item->origin_url) == self::normalize_url($origin_url)) {\n                return $item->proxy_url;\n            }\n        }, null);\n    }\n\n    private static function normalize_url($url)\n    {\n        $url = trim($url);\n\n        return preg_replace('/^https?:\\/\\//', '', $url);\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/feed_pusher.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass FeedPusher\n{\n    private $module;\n    private $api;\n\n    public function __construct($module, $api)\n    {\n        $this->module = $module;\n        $this->api = $api;\n    }\n\n    public function init()\n    {\n        add_action('admin_footer', function () {\n            if (get_option('podlove_plus_push_feeds')) {\n                $this->push_all_feeds();\n            }\n        });\n\n        // push all feeds to PLUS whenever any feed changes\n        add_action('podlove_model_change', function ($model) {\n            if (in_array($model::name(), ['podlove_feed'])) {\n                update_option('podlove_plus_push_feeds', true);\n            }\n        });\n\n        // push feeds when podcast changes\n        add_action('update_option_podlove_podcast', function () {\n            update_option('podlove_plus_push_feeds', true);\n        });\n\n        // push all feeds to PLUS when the feature is enabled\n        add_action('podlove_plus_enable_proxy_changed', function ($new_value) {\n            if ($new_value) {\n                update_option('podlove_plus_push_feeds', true);\n            }\n        });\n    }\n\n    public function push_all_feeds()\n    {\n        delete_option('podlove_plus_push_feeds');\n\n        // Bail out early if no PLUS token is configured.\n        $token = trim((string) $this->api->getToken());\n        if ($token === '') {\n            return;\n        }\n\n        // podcasts\n        $feeds = array_map(function ($feed) {\n            return $feed->get_subscribe_url();\n        }, \\Podlove\\Model\\Feed::all());\n\n        $this->api->push_feeds($feeds);\n\n        // shows\n        if (\\Podlove\\Modules\\Base::is_active('shows')) {\n            $shows = \\Podlove\\Modules\\Shows\\Model\\Show::all();\n            foreach ($shows as $show) {\n                $feeds = array_map(function ($feed) use ($show) {\n                    return $feed->get_subscribe_url('shows', $show->id);\n                }, \\Podlove\\Model\\Feed::all());\n\n                $this->api->push_feeds($feeds);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/file_storage.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nuse Podlove\\Model\\Podcast;\n\nclass FileStorage\n{\n    private $module;\n    private $api;\n\n    public function __construct($module, $api)\n    {\n        $this->module = $module;\n        $this->api = $api;\n    }\n\n    public function init()\n    {\n        add_action('podlove_before_assign_assets_settings', [$this, 'settings_card']);\n        add_action('podlove_data_js', [$this, 'extend_data_js']);\n\n        add_filter('podlove_file_url_template', [self::class, 'file_url_template']);\n        add_filter('podlove_media_file_base_uri', [self::class, 'file_url_base']);\n        add_filter('podlove_url_template_field_config', [$this, 'modify_url_template_field']);\n\n        if (self::is_enabled()) {\n            add_filter('podlove_podcast_settings_tabs', [$this, 'remove_media_tab']);\n        }\n    }\n\n    // the upload location is set automatically when PLUS is enabled\n    public function remove_media_tab($tabs)\n    {\n        $tabs->removeTab('media');\n\n        return $tabs;\n    }\n\n    public static function file_url_base($url_base = null)\n    {\n        if (self::is_enabled()) {\n            $base_url = Plus::base_url();\n            $podcast = Podcast::get();\n            $url_base = trailingslashit($base_url).'download/'.$podcast->plus_slug.'/';\n        }\n\n        return $url_base;\n    }\n\n    public static function file_url_template($template)\n    {\n        if (self::is_enabled()) {\n            $template = trailingslashit(self::file_url_base()).'%episode_slug%%suffix%.%format_extension%';\n        }\n\n        return $template;\n    }\n\n    public static function get_local_file_url($file)\n    {\n        if (self::is_enabled()) {\n            // Get local URL by temporarily removing the filter\n            remove_filter('podlove_media_file_base_uri', [self::class, 'file_url_base']);\n            remove_filter('podlove_file_url_template', [self::class, 'file_url_template']);\n            $local_url = $file->get_file_url();\n            add_filter('podlove_media_file_base_uri', [self::class, 'file_url_base']);\n            add_filter('podlove_file_url_template', [self::class, 'file_url_template']);\n\n            return $local_url;\n        }\n\n        return $file->get_file_url();\n    }\n\n    public static function is_enabled()\n    {\n        return Podcast::get()->plus_enable_storage;\n    }\n\n    public function extend_data_js($data)\n    {\n        if (!isset($data['plus'])) {\n            $data['plus'] = [];\n        }\n\n        $data['plus']['storage_enabled'] = self::is_enabled();\n\n        return $data;\n    }\n\n    // advertise the file storage if it is not enabled\n    public function settings_card()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        if (!$podcast->plus_enable_storage) {\n            Banner::file_storage();\n        }\n    }\n\n    public function modify_url_template_field($config)\n    {\n        if (self::is_enabled()) {\n            $config['attributes'] = 'class=\"large-text\" readonly disabled style=\"background-color: #f0f0f0; color: #666;\"';\n            $config['description'] = '<strong>'.__('This setting is managed automatically by PLUS Podcast File Hosting.', 'podlove-podcasting-plugin-for-wordpress').'</strong>';\n        }\n\n        return $config;\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/global_feed_settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\n/**\n * Global Feed Settings.\n *\n * Render and manage settings card on global feeds page.\n */\nclass GlobalFeedSettings\n{\n    private $module;\n    private $api;\n\n    public function __construct($module, $api)\n    {\n        $this->module = $module;\n        $this->api = $api;\n    }\n\n    public function init()\n    {\n        add_action('podlove_before_feed_global_settings', [$this, 'global_feed_setting']);\n        add_action('podlove_feed_settings_proxy', [$this, 'single_feed_proxy_setting'], 10, 2);\n        add_filter('podlove_feed_table_url', [$this, 'podlove_feed_table_url'], 10, 2);\n    }\n\n    public function podlove_feed_table_url($link, $feed)\n    {\n        $proxy_url = FeedProxy::get_proxy_url($feed->get_subscribe_url());\n        $link .= '<br><span title=\"redirects to\">&#8618;</span>&nbsp;';\n        if ($proxy_url) {\n            $link .= \"<a target=\\\"_blank\\\" href=\\\"{$proxy_url}\\\">{$proxy_url}</a>\";\n        } else {\n            $link .= 'error: unknown redirect URL';\n        }\n\n        return $link;\n    }\n\n    public function single_feed_proxy_setting($wrapper, $feed)\n    {\n        $proxy_url = FeedProxy::get_proxy_url($feed->get_subscribe_url());\n\n        $wrapper->callback('plus_redirect_info', [\n            'label' => __('PLUS Proxy', 'podlove-podcasting-plugin-for-wordpress'),\n            'callback' => function () use ($proxy_url) {\n                echo '<p>';\n                echo '<a target=\"_blank\" href=\"'.esc_attr($proxy_url).'\">'.$proxy_url.'</a>';\n                echo '</p>';\n                echo '<p class=\"description\">';\n                echo __('You are using Publisher PLUS Reliable Feed Delivery, which automatically configures these settings for you.', 'podlove-podcasting-plugin-for-wordpress');\n                echo '</p>';\n            },\n        ]);\n    }\n\n    // advertise the feed proxy if it is not enabled\n    public function global_feed_setting()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        if (!$podcast->plus_enable_proxy) {\n            Banner::feed_proxy();\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/growth_banner.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass GrowthBanner\n{\n    public const MIN_EPISODES = 10;\n    public const DISMISS_NONCE_ACTION = 'podlove_plus_growth_banner_dismiss';\n    public const BANNER_NAME = PromotionCoordinator::GROWTH_BANNER;\n\n    private $coordinator;\n\n    public function __construct(PromotionCoordinator $coordinator)\n    {\n        $this->coordinator = $coordinator;\n    }\n\n    public function init()\n    {\n        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);\n        add_action('admin_init', [$this, 'maybe_handle_dismiss']);\n        add_action('admin_notices', [$this, 'render']);\n        add_action('wp_ajax_podlove_plus_growth_banner_dismiss', [$this, 'ajax_dismiss']);\n    }\n\n    public function enqueue_assets()\n    {\n        if (!$this->should_render()) {\n            return;\n        }\n\n        $version = \\Podlove\\get_plugin_header('Version');\n        wp_enqueue_style('podlove-admin', \\Podlove\\PLUGIN_URL.'/css/admin.css', [], $version);\n        wp_enqueue_style('podlove-admin-font', \\Podlove\\PLUGIN_URL.'/css/admin-font.css', [], $version);\n    }\n\n    public function render()\n    {\n        if (!$this->should_render()) {\n            return;\n        }\n\n        $dismiss_url = wp_nonce_url(add_query_arg('podlove_dismiss_plus_growth_banner', '1'), self::DISMISS_NONCE_ACTION);\n        ?>\n        <div id=\"podlove-plus-growth-banner-wrap\" style=\"margin: 20px 20px 0 2px;\">\n            <div id=\"podlove-plus-growth-banner\" class=\"plus-banner\" style=\"max-width: none;\">\n                <a\n                    class=\"podlove-plus-growth-banner-dismiss\"\n                    href=\"<?php echo esc_url($dismiss_url); ?>\"\n                    aria-label=\"<?php esc_attr_e('Dismiss', 'podlove-podcasting-plugin-for-wordpress'); ?>\"\n                    style=\"position: absolute; top: 12px; right: 14px; color: rgba(255, 255, 255, 0.85); text-decoration: none; font-size: 22px; line-height: 1;\"\n                >&times;</a>\n                <h3><?php esc_html_e('Make your podcast delivery more reliable', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n                <div class=\"plus-banner-content\">\n                    <p><?php esc_html_e('Publisher PLUS helps you keep your feed fast during traffic spikes and host your podcast files on infrastructure built for podcast delivery, so your WordPress site has less to handle.', 'podlove-podcasting-plugin-for-wordpress'); ?></p>\n                </div>\n                <div class=\"plus-banner-footer\">\n                    <a href=\"<?php echo esc_url(admin_url('admin.php?page=publisher_plus_settings')); ?>\" class=\"btn\">\n                        <?php esc_html_e('Explore Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                    </a>\n                    <div class=\"corner-logo\">\n                        <svg class=\"logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 99.32 160.81\" style=\"width: 18px; height: 24px;\">\n                            <path fill=\"#ffffff\" d=\"M78.119 9c6.728 0 12.201 5.474 12.201 12.202V139.61c0 6.728-5.474 12.201-12.201 12.201H21.2c-6.727 0-12.2-5.473-12.2-12.201V21.202C9 14.474 14.473 9 21.2 9zm0-9H21.2C9.493 0 0 9.493 0 21.202V139.61c0 11.708 9.493 21.201 21.2 21.201h56.919c11.71 0 21.201-9.492 21.201-21.201V21.202C99.32 9.493 89.829 0 78.119 0z\"/>\n                            <path fill=\"#ffffff\" d=\"M49.576 90.412c12.742 0 23.069 10.327 23.069 23.068 0 12.74-10.327 23.069-23.069 23.069-12.738 0-23.067-10.329-23.067-23.069 0-12.741 10.329-23.068 23.067-23.068m0-9c-17.682 0-32.067 14.386-32.067 32.068 0 17.683 14.385 32.069 32.067 32.069 17.683 0 32.069-14.386 32.069-32.069.001-17.682-14.386-32.068-32.069-32.068z\"/>\n                            <g clip-rule=\"evenodd\">\n                                <path fill=\"none\" stroke=\"#ffffff\" stroke-miterlimit=\"10\" stroke-width=\"9\" d=\"M72.895 46.223l-23.57 23.583L25.758 46.22c-2.649-2.7-4.285-6.399-4.285-10.481 0-8.267 6.702-14.968 14.968-14.968 5.485 0 10.278 2.949 12.885 7.347 2.606-4.398 7.401-7.347 12.884-7.347 8.268 0 14.97 6.701 14.97 14.968 0 4.082-1.636 7.783-4.285 10.484z\"/>\n                                <path fill=\"#ffffff\" fill-rule=\"evenodd\" d=\"M49.577 105.223c4.561 0 8.26 3.698 8.26 8.257 0 4.562-3.699 8.258-8.26 8.258-4.56 0-8.257-3.696-8.257-8.258 0-4.559 3.697-8.257 8.257-8.257z\"/>\n                            </g>\n                        </svg>\n                        <div class=\"logo-text\"><?php esc_html_e('Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'); ?></div>\n                    </div>\n                </div>\n            </div>\n        </div>\n        <script>\n            (function() {\n                document.addEventListener('click', function(event) {\n                    const dismissButton = event.target.closest('#podlove-plus-growth-banner .podlove-plus-growth-banner-dismiss');\n                    if (!dismissButton) {\n                        return;\n                    }\n\n                    const data = new window.FormData();\n                    data.append('action', 'podlove_plus_growth_banner_dismiss');\n                    data.append('_ajax_nonce', '<?php echo esc_js(wp_create_nonce(self::DISMISS_NONCE_ACTION)); ?>');\n\n                    fetch(ajaxurl, {\n                        method: 'POST',\n                        body: data,\n                        credentials: 'same-origin'\n                    });\n                });\n            }());\n        </script>\n        <?php\n    }\n\n    public function maybe_handle_dismiss()\n    {\n        if (!isset($_GET['podlove_dismiss_plus_growth_banner'])) {\n            return;\n        }\n\n        if (!current_user_can('manage_options')) {\n            return;\n        }\n\n        check_admin_referer(self::DISMISS_NONCE_ACTION);\n        $this->coordinator->dismiss(self::BANNER_NAME);\n\n        wp_safe_redirect(remove_query_arg(['podlove_dismiss_plus_growth_banner', '_wpnonce']));\n        exit;\n    }\n\n    public function ajax_dismiss()\n    {\n        check_ajax_referer(self::DISMISS_NONCE_ACTION);\n\n        if (!current_user_can('manage_options')) {\n            wp_send_json_error([], 403);\n        }\n\n        $this->coordinator->dismiss(self::BANNER_NAME);\n        wp_send_json_success();\n    }\n\n    private function should_render()\n    {\n        return $this->coordinator->should_render(self::BANNER_NAME);\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/plus.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nuse Podlove\\Model\\Podcast;\n\nclass Plus extends \\Podlove\\Modules\\Base\n{\n    public $settings_page;\n    public $global_feed_settings;\n    public $feed_pusher;\n    public $feed_proxy;\n    public $file_storage;\n    public $growth_banner;\n    public $early_file_hosting_banner;\n    protected $module_name = 'Publisher PLUS';\n    protected $module_description = 'Publisher PLUS provides additional features and services for your podcast.';\n    protected $module_group = 'external services';\n\n    private $api;\n\n    public function load()\n    {\n        // fixme: refactor all uses of 'plus_api_token' except here\n        $token = defined('PODLOVE_PLUS_TOKEN') ? PODLOVE_PLUS_TOKEN : $this->get_module_option('plus_api_token');\n        $this->api = new API($this, $token);\n\n        $this->settings_page = new SettingsPage($this, $this->api);\n        $this->settings_page->init();\n\n        $this->global_feed_settings = new GlobalFeedSettings($this, $this->api);\n        $this->global_feed_settings->init();\n\n        $this->feed_pusher = new FeedPusher($this, $this->api);\n        $this->feed_pusher->init();\n\n        $this->feed_proxy = new FeedProxy($this, $this->api);\n        $this->feed_proxy->init();\n\n        $this->file_storage = new FileStorage($this, $this->api);\n        $this->file_storage->init();\n\n        $promotion_coordinator = new PromotionCoordinator($this);\n\n        add_filter('podlove_admin_promo_banner_active', function ($active) use ($promotion_coordinator) {\n            return $active || $promotion_coordinator->has_active_banner();\n        });\n\n        $this->growth_banner = new GrowthBanner($promotion_coordinator);\n        $this->growth_banner->init();\n\n        $this->early_file_hosting_banner = new EarlyFileHostingBanner($promotion_coordinator);\n        $this->early_file_hosting_banner->init();\n\n        add_action('rest_api_init', function () {\n            $controller = new RestApi($this->api);\n            $controller->register_routes();\n        });\n\n        // update podcast title in PLUS when\n        // - podcast title changes locally or\n        // - storage is enabled by user\n        //\n        // We do this to ensure that the _slug_ of the podcast is always up to\n        // date in PLUS because it is used as part of the media download URL.\n        add_action('update_option_podlove_podcast', function ($old_value, $new_value) {\n            if (trim((string) $this->api->getToken()) === '') {\n                return;\n            }\n\n            if ($old_value['title'] !== $new_value['title'] || $old_value['guid'] !== $new_value['guid']) {\n                $this->update_podcast_title_and_slug($new_value['guid'], $new_value['title']);\n            }\n        }, 10, 2);\n\n        $sync_podcast_title_and_slug = function () {\n            if (trim((string) $this->api->getToken()) === '') {\n                return;\n            }\n\n            $podcast = Podcast::get();\n            if ($podcast->title) {\n                $this->update_podcast_title_and_slug($podcast->guid ?? '', $podcast->title);\n            }\n        };\n\n        add_action('update_option_podlove_module_plus', function ($old_value, $new_value) use ($sync_podcast_title_and_slug) {\n            $old_token = $old_value['plus_api_token'] ?? '';\n            $new_token = $new_value['plus_api_token'] ?? '';\n            if ($old_token !== $new_token && trim((string) $new_token) !== '') {\n                $sync_podcast_title_and_slug();\n            }\n        }, 10, 2);\n\n        add_action('add_option_podlove_module_plus', function ($option, $value) use ($sync_podcast_title_and_slug) {\n            $token = $value['plus_api_token'] ?? '';\n            if (trim((string) $token) !== '') {\n                $sync_podcast_title_and_slug();\n            }\n        }, 10, 2);\n\n        add_action('podlove_plus_enable_storage_changed', function ($new_value) {\n            $podcast = Podcast::get();\n            if ($new_value && $podcast->title) {\n                $this->update_podcast_title_and_slug($podcast->guid ?? '', $podcast->title);\n            }\n        });\n    }\n\n    public function get_api()\n    {\n        return $this->api;\n    }\n\n    public static function base_url()\n    {\n        if (defined('PODLOVE_PLUS_BASE_URL')) {\n            return PODLOVE_PLUS_BASE_URL;\n        }\n\n        return apply_filters('podlove_plus_base_url', 'https://plus.podlove.org');\n    }\n\n    /**\n     * Updates the podcast title in PLUS and saves the returned slug.\n     *\n     * @param string $guid  Podcast GUID\n     * @param string $title Podcast title\n     */\n    private function update_podcast_title_and_slug(string $guid, string $title)\n    {\n        static $sync_in_progress = false;\n\n        if ($sync_in_progress || trim($title) === '') {\n            return;\n        }\n\n        $sync_in_progress = true;\n\n        try {\n            if (trim($guid) === '') {\n                $podcast = Podcast::get();\n\n                if (!$podcast->guid) {\n                    $podcast->guid = \\Ramsey\\Uuid\\Uuid::uuid4();\n                    $podcast->save();\n                }\n\n                $guid = (string) $podcast->guid;\n            }\n\n            if (trim($guid) === '') {\n                return;\n            }\n\n            $response = $this->api->upsert_podcast_title($guid, $title);\n            if ($response && $response->slug) {\n                $podcast = Podcast::get();\n                $podcast->plus_slug = $response->slug;\n                $podcast->save();\n            }\n        } finally {\n            $sync_in_progress = false;\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/promotion_coordinator.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Onboarding\\Onboarding;\n\nclass PromotionCoordinator\n{\n    public const STATE_OPTION = 'podlove_plus_promo_state';\n    public const LEGACY_GROWTH_DISMISSED_OPTION = 'podlove_plus_growth_banner_dismissed';\n    public const DEFAULT_COOLDOWN_DAYS = 30;\n    public const EARLY_FILE_HOSTING_BANNER = 'early_file_hosting';\n    public const GROWTH_BANNER = 'growth';\n\n    private $module;\n\n    public function __construct($module)\n    {\n        $this->module = $module;\n    }\n\n    public function should_render(string $banner): bool\n    {\n        return $this->winner() === $banner;\n    }\n\n    public function has_active_banner(): bool\n    {\n        return $this->winner() !== null;\n    }\n\n    public function dismiss(string $banner): void\n    {\n        update_option(self::STATE_OPTION, [\n            'banner' => $banner,\n            'dismissed_at' => time(),\n        ]);\n    }\n\n    public function winner(): ?string\n    {\n        if (!$this->base_conditions_met()) {\n            return null;\n        }\n\n        if ($this->growth_conditions_met()) {\n            return self::GROWTH_BANNER;\n        }\n\n        if ($this->early_file_hosting_conditions_met()) {\n            return self::EARLY_FILE_HOSTING_BANNER;\n        }\n\n        return null;\n    }\n\n    public function is_plus_configured(): bool\n    {\n        $podcast = Podcast::get();\n        $token = trim((string) $this->module->get_module_option('plus_api_token'));\n\n        return $token !== '' || $podcast->plus_enable_proxy || $podcast->plus_enable_storage;\n    }\n\n    private function base_conditions_met(): bool\n    {\n        if (!is_admin() || !current_user_can('manage_options')) {\n            return false;\n        }\n\n        if (!$this->is_supported_admin_page()) {\n            return false;\n        }\n\n        if ($this->is_plus_configured()) {\n            return false;\n        }\n\n        if ($this->is_in_cooldown()) {\n            return false;\n        }\n\n        if ($this->current_page() === 'publisher_plus_settings') {\n            return false;\n        }\n\n        if ($this->current_page() === 'podlove_settings_onboarding_handle') {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function growth_conditions_met(): bool\n    {\n        return $this->published_episode_count() >= GrowthBanner::MIN_EPISODES;\n    }\n\n    private function early_file_hosting_conditions_met(): bool\n    {\n        if ($this->published_episode_count() >= 1) {\n            return true;\n        }\n\n        return in_array(Onboarding::get_onboarding_type(), ['start', 'import'], true);\n    }\n\n    private function is_in_cooldown(): bool\n    {\n        if (get_option(self::LEGACY_GROWTH_DISMISSED_OPTION)) {\n            return true;\n        }\n\n        $state = get_option(self::STATE_OPTION, []);\n        $dismissed_at = (int) ($state['dismissed_at'] ?? 0);\n\n        if ($dismissed_at < 1) {\n            return false;\n        }\n\n        $cooldown_days = (int) apply_filters('podlove_plus_promo_cooldown_days', self::DEFAULT_COOLDOWN_DAYS);\n\n        if ($cooldown_days < 1) {\n            return false;\n        }\n\n        return (time() - $dismissed_at) < ($cooldown_days * DAY_IN_SECONDS);\n    }\n\n    private function published_episode_count(): int\n    {\n        $counts = wp_count_posts('podcast');\n\n        if (!$counts) {\n            return 0;\n        }\n\n        return (int) ($counts->publish ?? 0) + (int) ($counts->private ?? 0);\n    }\n\n    private function is_supported_admin_page(): bool\n    {\n        if ($this->current_admin_file() === 'edit.php' && $this->current_post_type() === 'podcast') {\n            return true;\n        }\n\n        $page = $this->current_page();\n\n        return $page !== '' && strpos($page, 'podlove_') === 0;\n    }\n\n    private function current_admin_file(): string\n    {\n        global $pagenow;\n\n        if (!is_string($pagenow)) {\n            return '';\n        }\n\n        return $pagenow;\n    }\n\n    private function current_page(): string\n    {\n        if (!isset($_GET['page'])) {\n            return '';\n        }\n\n        return sanitize_key(wp_unslash($_GET['page']));\n    }\n\n    private function current_post_type(): string\n    {\n        if (!isset($_GET['post_type'])) {\n            return '';\n        }\n\n        return sanitize_key(wp_unslash($_GET['post_type']));\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass RestApi extends \\WP_REST_Controller\n{\n    private $api;\n\n    public function __construct(API $api)\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'plus';\n\n        $this->api = $api;\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/create_file_upload', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_upload_url'],\n                'permission_callback' => [$this, 'get_permissions_check'],\n                [\n                    'args' => [\n                        'filename' => [\n                            'type' => 'string'\n                        ]\n                    ]\n                ]\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/check_file_exists', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'check_file_exists'],\n                'permission_callback' => [$this, 'get_permissions_check'],\n                [\n                    'args' => [\n                        'filename' => [\n                            'type' => 'string'\n                        ]\n                    ]\n                ]\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/complete_file_upload', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'complete_upload'],\n                'permission_callback' => [$this, 'get_permissions_check'],\n                [\n                    'args' => [\n                        'filename' => [\n                            'type' => 'string'\n                        ]\n                    ]\n                ]\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/migrate_file', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'migrate_file'],\n                'permission_callback' => [$this, 'get_migration_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/set_migration_complete', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'set_migration_complete'],\n                'permission_callback' => [$this, 'get_migration_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/get_migration_status', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_migration_status'],\n                'permission_callback' => [$this, 'get_migration_permissions_check'],\n            ]\n        ]);\n\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/generate_filename', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'generate_filename'],\n                'permission_callback' => [$this, 'get_permissions_check'],\n                'args' => [\n                    'original_filename' => [\n                        'type' => 'string',\n                        'required' => true,\n                        'description' => 'The original filename to generate new filename from'\n                    ],\n                    'episode_id' => [\n                        'type' => 'integer',\n                        'required' => true,\n                        'description' => 'The episode ID to generate filename for'\n                    ]\n                ]\n            ]\n        ]);\n    }\n\n    public function create_upload_url($request)\n    {\n        if ($error = $this->require_token()) {\n            return $error;\n        }\n\n        $filename = $request->get_param('filename');\n\n        return $this->api->create_file_upload($filename);\n    }\n\n    public function check_file_exists($request)\n    {\n        if ($error = $this->require_token()) {\n            return $error;\n        }\n\n        $filename = $request->get_param('filename');\n\n        return $this->api->check_file_exists($filename);\n    }\n\n    public function complete_upload($request)\n    {\n        if ($error = $this->require_token()) {\n            return $error;\n        }\n\n        $filename = $request->get_param('filename');\n\n        return $this->api->complete_file_upload($filename);\n    }\n\n    public function get_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function get_migration_permissions_check($request)\n    {\n        if (!current_user_can('administrator')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function migrate_file($request)\n    {\n        if ($error = $this->require_token()) {\n            return $error;\n        }\n\n        $filename = $request->get_param('filename');\n        $file_url = $request->get_param('file_url');\n\n        return $this->api->migrate_file($filename, $file_url);\n    }\n\n    public function set_migration_complete($request)\n    {\n        return $this->api->set_migration_complete();\n    }\n\n    public function get_migration_status($request)\n    {\n        return [\n            'is_complete' => $this->api->is_migration_complete()\n        ];\n    }\n\n    public function generate_filename($request)\n    {\n        $original_filename = $request->get_param('original_filename');\n        $episode_id = $request->get_param('episode_id');\n\n        $episode = \\Podlove\\Model\\Episode::find_by_id($episode_id);\n        if (!$episode) {\n            return new \\WP_Error('episode_not_found', 'Episode not found', ['status' => 404]);\n        }\n\n        // Use the Auphonic PlusFileTransfer logic for generating filenames\n        $filename = \\Podlove\\Modules\\Auphonic\\PlusFileTransfer::generate_filename($original_filename, $episode);\n\n        return [\n            'original_filename' => $original_filename,\n            'generated_filename' => $filename,\n            'episode_id' => $episode_id\n        ];\n    }\n\n    private function require_token()\n    {\n        $token = trim((string) $this->api->getToken());\n        if ($token === '') {\n            return new \\WP_Error(\n                'podlove_plus_token_missing',\n                'Publisher PLUS API token is not configured. Set it in Publisher PLUS settings.',\n                ['status' => 400]\n            );\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "lib/modules/plus/settings_page.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Plus;\n\nclass SettingsPage\n{\n    private $module;\n    private $api;\n\n    public function __construct($module, $api)\n    {\n        $this->module = $module;\n        $this->api = $api;\n    }\n\n    public function init()\n    {\n        add_action('admin_menu', [$this, 'add_admin_menu'], 275);\n    }\n\n    public function add_admin_menu()\n    {\n        add_submenu_page(\n            'podlove_settings_handle',\n            __('Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'),\n            __('Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'),\n            'administrator',\n            'publisher_plus_settings',\n            [$this, 'render_settings_page']\n        );\n    }\n\n    public function render_settings_page()\n    {\n        ?>\n        <div class=\"wrap\">\n            <div style=\"display: flex; align-items: center; gap: 1rem;\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18.5\" height=\"30\" viewBox=\"0 0 99.32 160.81\">\n                    <path fill=\"#181716\" d=\"M78.119 9c6.728 0 12.201 5.474 12.201 12.202V139.61c0 6.728-5.474 12.201-12.201 12.201H21.2c-6.727 0-12.2-5.473-12.2-12.201V21.202C9 14.474 14.473 9 21.2 9zm0-9H21.2C9.493 0 0 9.493 0 21.202V139.61c0 11.708 9.493 21.201 21.2 21.201h56.919c11.71 0 21.201-9.492 21.201-21.201V21.202C99.32 9.493 89.829 0 78.119 0z\"/>\n                    <path fill=\"#181716\" d=\"M49.576 90.412c12.742 0 23.069 10.327 23.069 23.068 0 12.74-10.327 23.069-23.069 23.069-12.738 0-23.067-10.329-23.067-23.069 0-12.741 10.329-23.068 23.067-23.068m0-9c-17.682 0-32.067 14.386-32.067 32.068 0 17.683 14.385 32.069 32.067 32.069 17.683 0 32.069-14.386 32.069-32.069.001-17.682-14.386-32.068-32.069-32.068z\"/>\n                    <g clip-rule=\"evenodd\">\n                        <path fill=\"none\" stroke=\"#181716\" stroke-miterlimit=\"10\" stroke-width=\"9\" d=\"M72.895 46.223l-23.57 23.583L25.758 46.22c-2.649-2.7-4.285-6.399-4.285-10.481 0-8.267 6.702-14.968 14.968-14.968 5.485 0 10.278 2.949 12.885 7.347 2.606-4.398 7.401-7.347 12.884-7.347 8.268 0 14.97 6.701 14.97 14.968 0 4.082-1.636 7.783-4.285 10.484z\"/>\n                        <path fill=\"#181716\" fill-rule=\"evenodd\" d=\"M49.577 105.223c4.561 0 8.26 3.698 8.26 8.257 0 4.562-3.699 8.258-8.26 8.258-4.56 0-8.257-3.696-8.257-8.258 0-4.559 3.697-8.257 8.257-8.257z\"/>\n                    </g>\n                </svg>\n                <h1 style=\"padding: 0;\"><?php echo __('Publisher PLUS', 'podlove-podcasting-plugin-for-wordpress'); ?></h1>\n            </div>\n\n            <div style=\"max-width: 800px;\">\n              <?php if ($this->module->get_module_option('plus_api_token')) {\n                  Banner::plus_authenticated();\n              } else {\n                  Banner::plus_main();\n              } ?>\n            </div>\n\n            <div data-client=\"podlove\" style=\"margin: 15px 0; max-width: 800px; \">\n              <podlove-plus-token/>\n            </div>\n\n            <div data-client=\"podlove\" style=\"margin: 15px 0; max-width: 800px; \">\n              <podlove-plus-features/>\n            </div>\n\n        </div>\n        <?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/media_tag_renderer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\PodloveWebPlayer\\PlayerV3\\PlayerMediaFiles;\n\nclass MediaTagRenderer\n{\n    public function __construct(Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    public function render($context, $attributes = [])\n    {\n        $player_media_files = new PlayerMediaFiles($this->episode);\n        $media_files = $player_media_files->get($context);\n\n        if (!$media_files) {\n            return '';\n        }\n\n        // build main audio/video tag\n        $xml = new \\SimpleXMLElement('<'.$player_media_files->media_xml_tag.'/>');\n        $xml->addAttribute('controls', 'controls');\n        $xml->addAttribute('preload', 'none');\n\n        if (count($attributes) > 0) {\n            foreach ($attributes as $key => $value) {\n                $xml->addAttribute($key, $value);\n            }\n        }\n\n        // add all sources\n        $xml = $this->add_sources($xml, $media_files);\n\n        // prettify and prepare to render\n        $xml_string = $xml->asXML();\n        // TODO: use DomDocumentFragment\n        $xml_string = $this->format_xml($xml_string);\n\n        return $this->remove_xml_header($xml_string);\n    }\n\n    public function add_sources($xml, $files)\n    {\n        $flash_fallback_func = function (&$xml) {};\n\n        foreach ($files as $file) {\n            $mime_type = $file['mime_type'];\n\n            $source = $xml->addChild('source');\n            $source->addAttribute('src', $file['publicUrl']);\n            $source->addAttribute('type', $mime_type);\n        }\n\n        return $xml;\n    }\n\n    private function format_xml($xml)\n    {\n        $dom = new \\DOMDocument('1.0');\n        $dom->preserveWhiteSpace = false;\n        $dom->formatOutput = true;\n        $dom->loadXML($xml);\n\n        return $dom->saveXML();\n    }\n\n    private function remove_xml_header($xml)\n    {\n        return trim(str_replace('<?xml version=\"1.0\"?>', '', $xml));\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_printer_interface.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer;\n\nuse Podlove\\Model\\Episode;\n\n/**\n * Interface for webplayer Printer.\n *\n * Every web player must provide a printer so it can be accessed\n * in shortcodes, templates etc.\n *\n * Example:\n *\n * class Printer implements PlayerPrinterInterface {\n *\n *   public function __construct(Episode $episode) {\n *     $this->episode = $episode;\n *   }\n *\n *   public function render($context = null) {\n *     return '<audio><source src=\"http://example.com/demo.m4a\" type=\"audio/mp4\"/></audio>';\n *   }\n *\n * }\n */\ninterface PlayerPrinterInterface\n{\n    /**\n     * Constructor takes episode for player.\n     */\n    public function __construct(Episode $episode);\n\n    /**\n     * Return rendered player HTML.\n     *\n     * @param string $context Optional string context. Correct header ist `$context = null`\n     *\n     * @return string\n     */\n    public function render($context);\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_v3/player_media_files.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\PlayerV3;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\EpisodeAsset;\nuse Podlove\\Model\\MediaFile;\n\nclass PlayerMediaFiles\n{\n    public $media_xml_tag = '';\n    private $episode;\n\n    private $audio_formats = ['mp3', 'mp4', 'ogg', 'opus'];\n    private $video_formats = ['mp4', 'ogg', 'webm'];\n\n    // List of Model\\MediaFile\n    private $files = [];\n\n    public function __construct(Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    public function get($context = null)\n    {\n        $this->files = $this->get_files();\n        $media_files = $this->media_files($context);\n\n        if (empty($media_files)) {\n            return '';\n        }\n\n        return $this->sort_files($media_files);\n    }\n\n    private function media_files($context)\n    {\n        $context = is_null($context) ? $this->get_tracking_context() : $context;\n\n        $media_files = [];\n        foreach ($this->files as $file) {\n            $asset = $file->episode_asset();\n            $mime = $asset->file_type()->mime_type;\n            $media_files[$mime] = [\n                'file' => $file,\n                'mime_type' => $mime,\n                'url' => $file->get_file_url(),\n                'publicUrl' => $file->get_public_file_url('webplayer', $context),\n                'assetTitle' => $asset->title(),\n                'size' => $file->size,\n                'extension' => $asset->file_type()->extension,\n            ];\n        }\n\n        return $media_files;\n    }\n\n    /**\n     * Sort files bases on mime type so preferred get output first.\n     *\n     * @param mixed $media_files\n     */\n    private function sort_files($media_files)\n    {\n        $sorted_files = [];\n        $preferred_order = ['audio/mp4', 'audio/aac', 'audio/opus', 'audio/ogg', 'audio/vorbis'];\n\n        foreach ($preferred_order as $order_key) {\n            if (isset($media_files[$order_key]) && $media_files[$order_key]) {\n                $sorted_files[] = $media_files[$order_key];\n                unset($media_files[$order_key]);\n            }\n        }\n\n        foreach ($media_files as $file) {\n            $sorted_files[] = $file;\n        }\n\n        return $sorted_files;\n    }\n\n    private function get_files()\n    {\n        $files = $this->get_playable_video_files();\n        $this->media_xml_tag = 'video';\n\n        if (count($files) == 0) {\n            $files = $this->get_playable_audio_files();\n            $this->media_xml_tag = 'audio';\n        }\n\n        return $files;\n    }\n\n    private function get_playable_video_files()\n    {\n        return $this->get_playable_files($this->video_formats, 'video');\n    }\n\n    private function get_playable_audio_files()\n    {\n        return $this->get_playable_files($this->audio_formats, 'audio');\n    }\n\n    /**\n     * Get playable files for player, based on episode and player assignments.\n     *\n     * @param array  $formats    array of formats like mp3, mp3, ogg, opus, webm\n     * @param string $media_type audio or video\n     *\n     * @return array of \\Podlove\\Model\\MediaFile\n     */\n    private function get_playable_files($formats, $media_type)\n    {\n        $playable_files = [];\n        $player_format_assignments = get_option('podlove_webplayer_formats');\n\n        if (empty($player_format_assignments)) {\n            error_log(print_r('Podlove Web Player: No assets are assigned.', true));\n\n            return [];\n        }\n\n        foreach ($formats as $format) {\n            if (!isset($player_format_assignments[$media_type][$format])) {\n                continue;\n            }\n\n            $episode_asset = EpisodeAsset::find_by_id($player_format_assignments[$media_type][$format]);\n            if (!$episode_asset) {\n                continue;\n            }\n\n            $media_file = MediaFile::find_by_episode_id_and_episode_asset_id($this->episode->id, $episode_asset->id);\n            if ($media_file && $media_file->is_valid()) {\n                $playable_files[] = $media_file;\n            }\n        }\n\n        return $playable_files;\n    }\n\n    private function get_tracking_context()\n    {\n        if (is_home()) {\n            return 'home';\n        }\n\n        if (is_single()) {\n            return 'episode';\n        }\n\n        return 'website';\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_v4/html5printer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\PlayerV4;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Contributors\\Model\\EpisodeContribution;\nuse Podlove\\Modules\\PodloveWebPlayer\\MediaTagRenderer;\nuse Podlove\\Modules\\PodloveWebPlayer\\PlayerV3\\PlayerMediaFiles;\n\nclass Html5Printer implements \\Podlove\\Modules\\PodloveWebPlayer\\PlayerPrinterInterface\n{\n    // Model\\Episode\n    private $episode;\n\n    private $player_id;\n\n    private $attributes = [];\n\n    public function __construct(Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    public function render($context = null)\n    {\n        if (!$this->episode) {\n            return '';\n        }\n\n        $id = $this->get_player_id();\n        $url = get_permalink($this->episode->post_id);\n        $url = add_query_arg('podlove_action', 'pwp4_config', $url);\n        $url = add_query_arg('podlove_context', $context, $url);\n\n        $media_xml = (new MediaTagRenderer($this->episode))->render($context);\n\n        // class \"intrinsic-ignore\" to avoid a twentytwenty issue:\n        // @see https: //community.podlove.org/t/podcast-player-4-klappt-auf-smartphone-immer-ein/2018/9\n        return '<div class=\"pwp4-wrapper intrinsic-ignore\" id=\"'.$id.'\" data-episode=\"'.$url.'\">'.$media_xml.'</div>';\n    }\n\n    public static function media_files($episode, $context)\n    {\n        $player_media_files = new PlayerMediaFiles($episode);\n\n        if ($media_files = $player_media_files->get($context)) {\n            $media_file_urls = array_map(function ($file) {\n                return [\n                    'url' => $file['publicUrl'],\n                    'size' => $file['size'],\n                    'title' => $file['assetTitle'],\n                    'mimeType' => $file['mime_type'],\n                ];\n            }, $media_files);\n        } elseif (is_admin()) {\n            $media_file_urls = [\n                'url' => \\Podlove\\PLUGIN_URL.'/bin/podlove.mp3',\n                'size' => 486839,\n                'title' => 'Podlove Example Audio',\n                'mimeType' => 'audio/mp3',\n            ];\n        } else {\n            $media_file_urls = [];\n        }\n\n        return $media_file_urls;\n    }\n\n    public static function config($episode, $context)\n    {\n        $podcast = Podcast::get();\n\n        $player_settings = \\Podlove\\get_webplayer_settings();\n\n        $config = [\n            'show' => [\n                'title' => $podcast->title,\n                'subtitle' => $podcast->subtitle,\n                'summary' => $podcast->summary,\n                'poster' => $podcast->cover_art()->setWidth(500)->url(),\n                'link' => \\Podlove\\get_landing_page_url(),\n            ],\n            'reference' => [],\n            'theme' => [\n                'main' => self::sanitize_color($player_settings['playerv4_color_primary'], '#000'),\n            ],\n            'visibleComponents' => array_keys($player_settings['playerv4_visible_components'], 'on'),\n        ];\n\n        if (Module::use_cdn()) {\n            $base = 'https://cdn.podlove.org/web-player/';\n        } else {\n            $base = trailingslashit(plugins_url('dist', __FILE__));\n        }\n\n        $config['reference']['base'] = $base;\n        $config['reference']['share'] = $base.'share.html';\n\n        if ($player_settings['playerv4_use_podcast_language']) {\n            $config = array_merge($config, [\n                'runtime' => [\n                    'language' => explode('-', $podcast->language)[0],\n                ],\n            ]);\n        }\n\n        $highlight_color = self::sanitize_color($player_settings['playerv4_color_secondary'], false);\n        if ($highlight_color !== false) {\n            $config['theme']['highlight'] = $highlight_color;\n        }\n\n        if ($episode) {\n            $post = get_post($episode->post_id);\n\n            $player_media_files = new PlayerMediaFiles($episode);\n\n            $episode_title = $post->post_title;\n\n            if (!$player_media_files->get($context) && is_admin()) {\n                $episode_title = __('Example Episode', 'podlove-podcasting-plugin-for-wordpress');\n            }\n\n            $media_file_urls = self::media_files($episode, $context);\n\n            $config = array_merge($config, [\n                'title' => $episode_title,\n                'subtitle' => trim($episode->subtitle),\n                'summary' => trim($episode->summary),\n                'publicationDate' => mysql2date('c', $post->post_date),\n                'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(),\n                'duration' => $episode->get_duration('full'),\n                'link' => get_permalink($episode->post_id),\n                'audio' => $media_file_urls,\n                'chapters' => array_map(function ($c) {\n                    $c->title = html_entity_decode(trim($c->title));\n\n                    return $c;\n                }, (array) json_decode($episode->get_chapters('json'))),\n            ]);\n\n            $config['reference']['config'] = self::config_url($episode);\n\n            if (\\Podlove\\Modules\\Base::is_active('contributors')) {\n                $config['contributors'] = array_filter(array_map(function ($c) {\n                    $contributor = $c->getContributor();\n\n                    if (!$contributor) {\n                        return [];\n                    }\n\n                    return [\n                        'id' => $contributor->id,\n                        'name' => $contributor->getName(),\n                        'avatar' => $contributor->avatar()->setWidth(150)->setHeight(150)->url(),\n                        'role' => $c->hasRole() ? $c->getRole()->to_array() : null,\n                        'group' => $c->hasGroup() ? $c->getGroup()->to_array() : null,\n                        'comment' => $c->comment,\n                    ];\n                }, EpisodeContribution::find_all_by_episode_id($episode->id)));\n            }\n        }\n\n        return apply_filters('podlove_player4_config', $config, $episode);\n    }\n\n    public static function config_url($episode)\n    {\n        return esc_url(add_query_arg('podlove_player4', $episode->id, trailingslashit(get_option('siteurl'))));\n    }\n\n    public static function sanitize_color($color, $default = '#000')\n    {\n        static $patterns = [\n            // 'cmyk'  => '/^(?:device-)?cmyk\\((\\d{1,3}),\\s*(\\d{1,3}),\\s*(\\d{1,3}),\\s*(\\d+(?:\\.\\d+)?|\\.\\d+)\\s*\\)/',\n            'rgba' => '/^rgba\\((\\d{1,3}),\\s*(\\d{1,3}),\\s*(\\d{1,3}),\\s*(\\d+(?:\\.\\d+)?|\\.\\d+)\\s*\\)/',\n            'rgb' => '/^rgb\\((\\d{1,3}),\\s*(\\d{1,3}),\\s*(\\d{1,3})\\)$/',\n            'hsla' => '/^hsla\\((\\d{1,3}),\\s*(\\d{1,3})%,\\s*(\\d{1,3})%,\\s*(\\d+(?:\\.\\d+)?|\\.\\d+)\\s*\\)/',\n            'hsl' => '/^hsl\\((\\d{1,3}),\\s*(\\d{1,3})%,\\s*(\\d{1,3})%\\)$/',\n            // 'hsva'  => '/^hsva\\((\\d{1,3}),\\s*(\\d{1,3})%,\\s*(\\d{1,3})%,\\s*(\\d+(?:\\.\\d+)?|\\.\\d+)\\s*\\)$/',\n            // 'hsv'   => '/^hsv\\((\\d{1,3}),\\s*(\\d{1,3})%,\\s*(\\d{1,3})%\\)$/',\n            'hex6' => '/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/',\n            'hex3' => '/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/',\n        ];\n\n        $color = trim($color);\n\n        // fix duplicate '#'\n        $color = preg_replace('/^[#]+/', '#', $color);\n        if (preg_match($patterns['hex6'], $color) || preg_match($patterns['hex3'], $color)) {\n            // add missing '#'\n            if ($color[0] != '#') {\n                $color = '#'.$color;\n            }\n\n            return $color;\n        }\n\n        // accept any known color format\n        foreach ($patterns as $pattern) {\n            if (preg_match($pattern, $color)) {\n                return $color;\n            }\n        }\n\n        return $default;\n    }\n\n    private function get_player_id()\n    {\n        if (!$this->player_id) {\n            $this->player_id = 'podlovewebplayer_'.sha1(microtime().rand());\n        }\n\n        return $this->player_id;\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_v4/module.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\PlayerV4;\n\nuse Podlove\\Model\\Episode;\n\nclass Module\n{\n    public function load()\n    {\n        add_action('wp_enqueue_scripts', [$this, 'register_scripts']);\n\n        if (isset($_GET['podlove_tab']) && $_GET['podlove_tab'] == 'player') {\n            add_action('admin_enqueue_scripts', [$this, 'register_scripts']);\n        }\n\n        // backward compatible, but only load if no other plugin has registered this shortcode\n        if (!shortcode_exists('podlove-web-player')) {\n            add_shortcode('podlove-web-player', [__CLASS__, 'shortcode']);\n        }\n\n        add_shortcode('podlove-episode-web-player', [__CLASS__, 'shortcode']);\n\n        add_filter('podlove_player_form_data', [$this, 'add_player_settings']);\n\n        add_action('wp', function () {\n            if (get_post_type() == 'podcast' && isset($_GET['podlove_action']) && $_GET['podlove_action'] == 'pwp4_config') {\n                $episode = Episode::find_or_create_by_post_id(get_the_ID());\n\n                if (!$episode) {\n                    \\Podlove\\AJAX\\Ajax::respond_with_json(['error' => 'no episode']);\n                    exit;\n                }\n\n                $context = isset($_GET['podlove_context']) ? $_GET['podlove_context'] : null;\n                $config = Html5Printer::config($episode, $context);\n\n                \\Podlove\\AJAX\\Ajax::respond_with_json($config);\n                exit;\n            }\n        });\n    }\n\n    public static function module()\n    {\n        return \\Podlove\\Modules\\PodloveWebPlayer\\Podlove_Web_Player::instance();\n    }\n\n    public static function use_cdn()\n    {\n        return self::module()->get_module_option('use_cdn', true);\n    }\n\n    public function register_scripts()\n    {\n        $version = \\Podlove\\get_plugin_header('Version');\n\n        wp_enqueue_script(\n            'podlove-player4-embed',\n            self::embed_script_url(self::use_cdn()),\n            [],\n            $version\n        );\n\n        $src = self::module()->get_module_url().'/player_v4/pwp4.js';\n\n        wp_enqueue_script('podlove-pwp4-player', $src, ['jquery'], $version);\n    }\n\n    public static function embed_script_url($use_cdn = true)\n    {\n        if ($use_cdn) {\n            return 'https://cdn.podlove.org/web-player/embed.js';\n        }\n\n        return plugins_url('dist/embed.js', __FILE__);\n    }\n\n    public static function shortcode($args = [])\n    {\n        if (is_feed()) {\n            return '';\n        }\n\n        if (isset($args['post_id'])) {\n            $post_id = $args['post_id'];\n            unset($args['post_id']);\n        } else {\n            $post_id = get_the_ID();\n        }\n\n        add_filter('podlove_player4_config', function ($config, $episode) use ($args) {\n            if (!is_array($args)) {\n                return $config;\n            }\n\n            foreach ($args as $key => $value) {\n                $key = str_ireplace('mimetype', 'mimeType', $key); // because shortcodes ignore case\n                $path = explode('_', $key);\n\n                if (count($path) === 1) {\n                    $config[$path[0]] = $value;\n                }\n\n                if (count($path) === 2) {\n                    if (!isset($config[$path[0]])) {\n                        $config[$path[0]] = [];\n                    }\n                    $config[$path[0]][$path[1]] = $value;\n                }\n\n                if (count($path) === 3) {\n                    if (!isset($config[$path[0]])) {\n                        $config[$path[0]] = [];\n                    }\n                    if (!isset($config[$path[0]][$path[1]])) {\n                        $config[$path[0]][$path[1]] = [];\n                    }\n                    $config[$path[0]][$path[1]][$path[2]] = $value;\n                }\n            }\n\n            return $config;\n        });\n\n        if (isset($args['mode']) && $args['mode'] == 'live') {\n            // live mode has no episode to reference\n            $printer = new Html5Printer();\n        } else {\n            $episode = Episode::find_one_by_post_id($post_id);\n\n            if (!$episode) {\n                return '';\n            }\n\n            $printer = new Html5Printer($episode);\n        }\n\n        return $printer->render(null);\n    }\n\n    public static function register_config_url_route()\n    {\n        add_action('init', [__CLASS__, 'config_url_route']);\n    }\n\n    public static function config_url_route()\n    {\n        if (!isset($_GET['podlove_player4'])) {\n            return;\n        }\n\n        $episode_id = (int) $_GET['podlove_player4'];\n\n        if (!$episode_id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($episode_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        // allow CORS\n\n        // Allow from any origin\n        if (isset($_SERVER['HTTP_ORIGIN'])) {\n            header(\"Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}\");\n            header('Access-Control-Allow-Credentials: true');\n            header('Access-Control-Max-Age: 86400'); // cache for 1 day\n        }\n\n        // Access-Control headers are received during OPTIONS requests\n        if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {\n            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {\n                header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n            }\n\n            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {\n                header(\"Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}\");\n            }\n\n            exit(0);\n        }\n\n        // other headers\n        header('Content-type: application/json');\n\n        $config = Html5Printer::config($episode, 'embed');\n        echo wp_json_encode($config);\n        exit;\n    }\n\n    public function add_player_settings($form_data)\n    {\n        $form_data[] = [\n            'type' => 'string',\n            'key' => 'playerv4_color_primary',\n            'options' => [\n                'label' => 'Primary Color',\n                'description' => __('Hex, rgb or rgba', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n            'position' => 500,\n        ];\n\n        $form_data[] = [\n            'type' => 'string',\n            'key' => 'playerv4_color_secondary',\n            'options' => [\n                'label' => 'Secondary Color (optional)',\n                'description' => __('Hex, rgb or rgba', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n            'position' => 495,\n        ];\n\n        $form_data[] = [\n            'type' => 'multiselect',\n            'key' => 'playerv4_visible_components',\n            'options' => [\n                'label' => 'Select Visible Components',\n                'description' => __('Select which player components you would like to display', 'podlove-podcasting-plugin-for-wordpress'),\n                'multi_values' => \\Podlove\\get_webplayer_settings()['playerv4_visible_components'],\n                'options' => [\n                    'controlChapters' => 'Chapters Control',\n                    'controlSteppers' => 'Playback Steppers',\n                    'episodeTitle' => 'Episode Title',\n                    'poster' => 'Episode Image',\n                    'progressbar' => 'Progress Bar',\n                    'showTitle' => 'Podcast Title',\n                    'subtitle' => 'Episode Subtitle',\n                    'tabAudio' => 'Audio Controls Tab',\n                    'tabChapters' => 'Chapters Tab',\n                    'tabFiles' => 'Download Tab',\n                    'tabInfo' => 'Info Tab',\n                    'tabShare' => 'Sharing Tab',\n                    'tabTranscripts' => 'Transcripts Tab',\n                ],\n            ],\n            'position' => 490,\n        ];\n\n        $form_data[] = [\n            'type' => 'checkbox',\n            'key' => 'playerv4_use_podcast_language',\n            'options' => [\n                'label' => __('Use podcast language for interface', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Tick to use the podcast language. Otherwise use the user\\'s browser language.', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n            'position' => 490,\n        ];\n\n        return $form_data;\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_v4/pwp4.js",
    "content": "jQuery(function () {\n  jQuery(\".pwp4-wrapper\").each(function () {\n    var that = jQuery(this);\n    var id = that.attr(\"id\");\n    var config = that.data(\"episode\");\n    \n    if (typeof podlovePlayer === \"function\") {\n      podlovePlayer(\"#\" + id, config);\n    }\n  })\n});\n"
  },
  {
    "path": "lib/modules/podlove_web_player/player_v5/module.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\PlayerV5;\n\nclass Module\n{\n    public function load()\n    {\n        add_action('admin_notices', [$this, 'check_plugin_active']);\n        add_filter('podlove_player_form_data', [$this, 'player_config_form_data']);\n    }\n\n    public function check_plugin_active()\n    {\n        $plugin = 'podlove-web-player/podlove-web-player.php';\n\n        if (!is_plugin_active($plugin)) {\n            $this->print_admin_notice();\n        }\n    }\n\n    public function player_config_form_data($config)\n    {\n        $config[] = [\n            'type' => 'callback',\n            'key' => 'pwp5_notice',\n            'options' => [\n                'label' => __('Podlove Web Player 5 Settings', 'podlove-podcasting-plugin-for-wordpress'),\n                'callback' => function () {\n                    echo __('Podlove Web Player 5 has its own settings page:', 'podlove-podcasting-plugin-for-wordpress');\n                    echo ' <a href=\"'.admin_url('options-general.php?page=podlove-web-player-settings').'\">'.__('go to player settings', 'podlove-podcasting-plugin-for-wordpress').'</a>';\n                },\n            ],\n            'position' => 10,\n        ];\n\n        return $config;\n    }\n\n    private function print_admin_notice()\n    {\n        ?>\n      <div class=\"update-message notice notice-warning notice-alt\">\n        <p>\n          <?php echo __('You need to install the Podlove Web Player plugin to use Podlove Web Player 5 with Podlove Publisher.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n           <a href=\"<?php echo admin_url('plugin-install.php?s=podlove+web+player&tab=search&type=term'); ?>\"><?php echo __('Install Now', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n        </p>\n      </div>\n      <?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/podigee/html5printer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\Podigee;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Feed;\nuse Podlove\\Model\\MediaFile;\n\nclass Html5Printer implements \\Podlove\\Modules\\PodloveWebPlayer\\PlayerPrinterInterface\n{\n    // Model\\Episode\n    private $episode;\n\n    private $config_var_name;\n\n    public function __construct(Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    public function render($context = null, $style = 'configfile')\n    {\n        // needs to be relative URL since it needs to request the config.json via XHR,\n        // which will be blocked if the protocol does not match the iframe protocol\n        $src = '//cdn.podigee.com/podcast-player/javascripts/podigee-podcast-player.js';\n\n        if ($style == 'inline') { // inline players are not embeddable\n            return '\n\t\t\t<script>window.'.$this->config_var_name().' = '.wp_json_encode(self::config($this->episode, $context)).'</script>\n\t\t\t<script class=\"podigee-podcast-player\" src=\"'.$src.'\" data-configuration=\"'.$this->config_var_name().'\"></script>';\n        }\n\n        return '<script class=\"podigee-podcast-player\" src=\"'.$src.'\" data-configuration=\"'.$this->config_url().'\"></script>';\n    }\n\n    public function config_url()\n    {\n        return esc_url(add_query_arg('podigee_player', $this->episode->id, trailingslashit(get_option('siteurl'))));\n    }\n\n    public static function config($episode, $context)\n    {\n        $post = get_post($episode->post_id);\n        $player_media_files = new \\Podlove\\Modules\\PodloveWebPlayer\\PlayerV3\\PlayerMediaFiles($episode);\n        $media_files = $player_media_files->get($context);\n        $media_files_conf = array_reduce($media_files, function ($agg, $item) {\n            $extension = $item['extension'];\n\n            if ($extension == 'oga') {\n                $extension = 'ogg';\n            }\n\n            $agg[$extension] = $item['url'];\n\n            return $agg;\n        }, []);\n\n        $config = [\n            'options' => [\n                'theme' => \\Podlove\\get_webplayer_setting('podigeetheme'),\n            ],\n            'extensions' => [\n                'EpisodeInfo' => [\n                    'showOnStart' => false,\n                ],\n                'ChapterMarks' => [\n                    'showOnStart' => false,\n                ],\n                'Share' => [],\n            ],\n            'podcast' => [\n                // don't provide the feed unless we have a CORS solution\n                // 'feed' => Feed::first()->get_subscribe_url()\n            ],\n            'episode' => [\n                'media' => $media_files_conf,\n                'title' => get_the_title($post->ID),\n                'subtitle' => wptexturize(convert_chars(trim($episode->subtitle))),\n                'description' => nl2br(wptexturize(convert_chars(trim($episode->summary)))),\n                'coverUrl' => $episode->cover_art_with_fallback()->setWidth(500)->url(),\n                'chaptermarks' => json_decode($episode->get_chapters('json')),\n                'url' => get_permalink($post->ID),\n            ],\n        ];\n\n        $player_assignments = get_option('podlove_webplayer_formats');\n        if ($player_assignments && isset($player_assignments['transcript'], $player_assignments['transcript']['transcript'])) {\n            $transcript_asset_id = (int) $player_assignments['transcript']['transcript'];\n            $transcript_media_file = MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $transcript_asset_id);\n\n            if ($transcript_media_file && $transcript_media_file->is_valid()) {\n                $config['extensions']['Transcript'] = [];\n                $config['episode']['Transcript'] = $transcript_media_file->get_public_file_url('webplayer');\n            }\n        }\n\n        foreach ($media_files as $file) {\n            switch ($file['mime_type']) {\n                case 'audio/mp4':  $ext = 'm4a';\n\n                    break;\n                case 'audio/opus': $ext = 'opus';\n\n                    break;\n                case 'audio/ogg':  $ext = 'ogg';\n\n                    break;\n                case 'audio/mpeg': $ext = 'mp3';\n\n                    break;\n\n                default: $ext = false;\n\n                    break;\n            }\n\n            if ($ext) {\n                $config['episode']['media'][$ext] = $file['publicUrl'];\n            }\n        }\n\n        return $config;\n    }\n\n    private function config_var_name()\n    {\n        if (!$this->config_var_name) {\n            $uuid = str_replace('.', '', uniqid('', true));\n            $this->config_var_name = 'player_'.$uuid;\n        }\n\n        return $this->config_var_name;\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/podigee/module.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer\\Podigee;\n\nuse Podlove\\Model\\Episode;\n\nclass Module\n{\n    public function load()\n    {\n        add_shortcode('podlove-episode-web-player', [__CLASS__, 'shortcode']);\n        add_filter('podlove_player_form_data', [$this, 'add_player_settings']);\n    }\n\n    public function add_player_settings($form_data)\n    {\n        $theme_options = [\n            'default' => 'Default',\n            'default-dark' => 'Default (dark)',\n            'minimal' => 'Minimal',\n        ];\n\n        $form_data[] = [\n            'type' => 'select',\n            'key' => 'podigeetheme',\n            'options' => [\n                'label' => 'Web Player Theme',\n                'options' => $theme_options,\n            ],\n            'position' => 500,\n        ];\n\n        return $form_data;\n    }\n\n    public static function shortcode()\n    {\n        if (is_feed()) {\n            return '';\n        }\n\n        $episode = Episode::find_or_create_by_post_id(get_the_ID());\n        $printer = new Html5Printer($episode);\n\n        return $printer->render(null);\n    }\n\n    public static function register_config_url_route()\n    {\n        add_action('init', [__CLASS__, 'config_url_route']);\n    }\n\n    public static function config_url_route()\n    {\n        if (!isset($_GET['podigee_player'])) {\n            return;\n        }\n\n        $episode_id = (int) $_GET['podigee_player'];\n\n        if (!$episode_id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($episode_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        // allow CORS\n\n        // Allow from any origin\n        if (isset($_SERVER['HTTP_ORIGIN'])) {\n            header(\"Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}\");\n            header('Access-Control-Allow-Credentials: true');\n            header('Access-Control-Max-Age: 86400');    // cache for 1 day\n        }\n\n        // Access-Control headers are received during OPTIONS requests\n        if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {\n            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {\n                header('Access-Control-Allow-Methods: GET, POST, OPTIONS');\n            }\n\n            if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {\n                header(\"Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}\");\n            }\n\n            exit(0);\n        }\n\n        // other headers\n        header('Content-type: application/json');\n\n        $config = Html5Printer::config($episode, 'embed');\n        echo wp_json_encode($config);\n        exit;\n    }\n}\n"
  },
  {
    "path": "lib/modules/podlove_web_player/podlove_web_player.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\PodloveWebPlayer;\n\nuse Podlove\\Model\\Episode;\n\nclass Podlove_Web_Player extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Podlove Web Player';\n    protected $module_description = 'An audio player for the web. Let users listen to your podcast right on your website';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        switch (\\Podlove\\get_webplayer_setting('version')) {\n            case 'player_v5':\n                (new PlayerV5\\Module())->load();\n\n                break;\n            case 'player_v4':\n                (new PlayerV4\\Module())->load();\n\n                break;\n            case 'podigee':\n                (new Podigee\\Module())->load();\n\n                break;\n        }\n\n        add_action('init', fn () => $this->register_option('use_cdn', 'radio', [\n            'label' => __('Use CDN?', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '<p>'.__('Use our CDN (https://cdn.podlove.org) to always have the current version of the player on your site. Alternatively deliver the player with your own WordPress instance with the disadvantage of not using the most recent version all the time. This setting only applies to Podlove Web Player 4.', 'podlove-podcasting-plugin-for-wordpress').'</p>',\n            'default' => '1',\n            'options' => [\n                1 => __('yes, use CDN', 'podlove-podcasting-plugin-for-wordpress').' ('.__('recommended', 'podlove-podcasting-plugin-for-wordpress').')',\n                0 => __('no, deliver with WordPress', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n        ]));\n\n        // this must _always_ be on, otherwise embedded players on other sites will stop working\n        Podigee\\Module::register_config_url_route();\n        PlayerV4\\Module::register_config_url_route();\n    }\n\n    public static function get_player_printer(Episode $episode)\n    {\n        switch (\\Podlove\\get_webplayer_setting('version')) {\n            case 'player_v4':\n                $printer = new PlayerV4\\Html5Printer($episode);\n\n                return $printer;\n\n                break;\n            case 'podigee':\n                return new Podigee\\Html5Printer($episode);\n\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/protected_feed/protected_feed.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\ProtectedFeed;\n\nclass Protected_Feed extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Protected Feeds';\n    protected $module_description = 'Protect feeds using HTTP Basic Authentication or require login credentials from WordPress. Warning: few clients support feed authentication.';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        add_action('pre_get_posts', [$this, 'inject_feed_protection']);\n        add_action('podlove_feed_settings_bottom', [$this, 'inject_feed_setting']);\n        add_filter('podlove_feed_list_table_columns', [$this, 'add_feed_list_protected_column']);\n    }\n\n    public function inject_feed_protection()\n    {\n        global $wp_query;\n\n        if (!is_feed()) {\n            return;\n        }\n\n        $feedname = get_query_var('feed');\n        $feed = \\Podlove\\Model\\Feed::find_one_by_property('slug', $feedname);\n\n        if (isset($feed) && $feed->protected == 1) {\n            if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {\n                self::send_authentication_headers();\n            } else {\n                switch ($feed->protection_type) {\n                    case '0':\n                        // A local User/PW combination is set\n                        if ($_SERVER['PHP_AUTH_USER'] == $feed->protection_user && $_SERVER['PHP_AUTH_PW'] == $feed->protection_password) {\n                            // let the script continue\n                            \\Podlove\\Feeds\\check_for_and_do_compression();\n                        } else {\n                            self::send_authentication_headers();\n                        }\n\n                        break;\n                    case '1':\n                        // The WordPress User db is used for authentification\n                        if (!username_exists($_SERVER['PHP_AUTH_USER'])) {\n                            self::send_authentication_headers();\n                        } else {\n                            $userinfo = get_user_by('login', $_SERVER['PHP_AUTH_USER']);\n                            if (wp_check_password($_SERVER['PHP_AUTH_PW'], $userinfo->data->user_pass, $userinfo->ID)) {\n                                // let the script continue\n                                \\Podlove\\Feeds\\check_for_and_do_compression();\n                            } else {\n                                self::send_authentication_headers();\n                            }\n                        }\n\n                        break;\n\n                    default:\n                        exit; // If the feed is protected and no auth method is selected exit the script\n\n                        break;\n                }\n            }\n        } else {\n            // compress unprotected feeds\n            \\Podlove\\Feeds\\check_for_and_do_compression();\n        }\n    }\n\n    public static function send_authentication_headers()\n    {\n        header('WWW-Authenticate: Basic realm=\"This feed is protected. Please login.\"');\n        header('HTTP/1.1 401 Unauthorized');\n        exit;\n    }\n\n    public function inject_feed_setting($wrapper)\n    {\n        $wrapper->subheader(__('Protection', 'podlove-podcasting-plugin-for-wordpress'));\n\n        $wrapper->checkbox('protected', [\n            'label' => __('Protect feed ', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('The feed will be protected by HTTP Basic Authentication.', 'podlove-podcasting-plugin-for-wordpress'),\n            'default' => false,\n        ]);\n\n        $wrapper->select('protection_type', [\n            'label' => __('Method', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n            'options' => [\n                '0' => 'Custom Login',\n                '1' => 'WordPress User database',\n            ],\n            'default' => -1,\n            'please_choose' => true,\n        ]);\n\n        $wrapper->string('protection_user', [\n            'label' => __('Username', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '',\n            'html' => ['class' => 'regular-text required'],\n        ]);\n\n        $wrapper->string('protection_password', [\n            'label' => __('Password', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '',\n            'html' => ['class' => 'regular-text required'],\n        ]);\n    }\n\n    public function add_feed_list_protected_column($columns)\n    {\n        $keys = array_keys($columns);\n        $insertIndex = array_search('discoverable', $keys) + 1; // after discoverable column\n\n        // insert at that index\n        return array_slice($columns, 0, $insertIndex, true)\n               + ['protected' => __('Protected', 'podlove-podcasting-plugin-for-wordpress')]\n               + array_slice($columns, $insertIndex, count($columns) - 1, true);\n    }\n}\n"
  },
  {
    "path": "lib/modules/pubsubhubbub/pubsubhubbub.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Pubsubhubbub;\n\nuse Podlove\\Model;\n\nclass Pubsubhubbub extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'PubSubHubbub Support';\n    protected $module_description = 'Adds PubSubHubbub discovery to your feeds. Ping services on feed updates.';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        add_action('init', [$this, 'register_hooks']);\n        add_action('init', [$this, 'register_module_option']);\n    }\n\n    /**\n     * Register hooks on episode pages only.\n     */\n    public function register_hooks()\n    {\n        $hub_url = $this->get_module_option('hub_url');\n\n        if (!$hub_url) {\n            return;\n        }\n\n        add_action('podlove_rss2_head', function ($feed) use ($hub_url) {\n            echo \"\\t\".sprintf('<atom:link rel=\"hub\" href=\"%s\" />', $hub_url);\n        });\n\n        add_action('save_post', [$this, 'announce_feed_changes'], 10, 2);\n    }\n\n    public function register_module_option()\n    {\n        $this->register_option('hub_url', 'string', [\n            'label' => __('Hub URL', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Use hub URL for all feeds.', 'podlove-podcasting-plugin-for-wordpress'),\n            'html' => [\n                'class' => 'regular-text podlove-check-input',\n                'data-podlove-input-type' => 'url',\n                'placeholder' => 'http://<your-hub-name>.superfeedr.com/',\n            ],\n        ]);\n    }\n\n    /**\n     * Ping hub for every feed.\n     *\n     * @todo do it in a wp cron for more faster UX\n     * @todo subscribe url or redirect=no url?\n     *\n     * @param mixed $post_ID\n     * @param mixed $post\n     */\n    public function announce_feed_changes($post_ID, $post)\n    {\n        if (get_post_type($post) !== 'podcast') {\n            return;\n        }\n\n        foreach (Model\\Feed::all() as $feed) {\n            $this->send_ping($feed->get_subscribe_url());\n        }\n    }\n\n    public function send_ping($ping_url)\n    {\n        $hub_url = $this->get_module_option('hub_url');\n\n        if (!$hub_url) {\n            return;\n        }\n\n        $curl = new \\Podlove\\Http\\Curl();\n        $curl->request($hub_url, [\n            'method' => 'POST',\n            'body' => 'hub.mode=publish&hub.url='.urlencode($ping_url),\n            'headers' => [\n                'Content-Type' => 'application/x-www-form-urlencoded; charset='.get_option('blog_charset'),\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/readme.md",
    "content": "# Podlove Modules\n\nModules can be compared to WordPress plugins. We use modules to keep the code base clean and decoupled. Furthermore, this system allows for easy activation/deactivation of modules without the risk to break stuff.\n\n## Creating A Module\n\n- each module lives in a separate directory in `/lib/modules`\n- each module contains at least one file containing the main module class\n- each module class inherits from `\\Podlove\\Modules\\Base`\n\nEach module has a `load()` method. This will be called to load the module. Here hooks can be registered, files be loaded etc. The module must not change any behavior before `load()` being called!\n\nThere should be protected properties `$module_name, $module_description`. They can be accessed from the outside via getters. See Base module.\n\n## Naming Conventions\n\nDirectories, file names and class names are snake cased. Directories and file names are all lowercased, class names are `Camel_Snake_Cased`. The namespace is CamelCased. Example:\n\n```\nPlugin Name: Podlove Web Player\nDirectory:   podlove_web_player\nFile Name:   podlove_web_player.php\nClass Name:  Podlove_Web_Player\nNamespace:   \\Podlove\\Modules\\PodloveWebPlayer\n```\n\n### Example Module File\n\n```php\n<?php\nnamespace Podlove\\Modules\\PodloveWebPlayer;\n\nclass Podlove_Web_Player extends \\Podlove\\Modules\\Base {\n\n\tprotected $module_name = 'Podlove Web Player';\n\tprotected $module_description = 'An audio player for the web';\n\n\tpublic function load() {\n\t\t// register actions\n\t\tadd_action( 'podlove_dashboard_meta_boxes', array( $this, 'register_meta_boxes' ) );\n\t\t// require additional module files\n\t\trequire_once 'player/podlove-web-player/podlove-web-player.php';\n\t}\n\n\tpublic function register_meta_boxes() {\n\t\t// code ...\n\t}\n\n}\n```\n"
  },
  {
    "path": "lib/modules/related_episodes/js/admin.js",
    "content": "var PODLOVE = PODLOVE || {};\n\n(function($) {\n\tfunction update_chosen() {\n\t\t$(\".chosen-related-episodes\").chosen({ width: '100%', search_contains: true });\n\t}\n\n\t$(document).ready(function() {\n\t\tvar i = 0;\n\n\t\t$(\"#episode-relation-form table\").podloveDataTable({\n\t\t\trowTemplate: \"#episode-relation-row-template\",\n\t\t\tdata: PODLOVE.related_episodes_existing_episode_relations,\n\t\t\tdataPresets: PODLOVE.related_episodes_existing_episodes,\n\t\t\taddRowHandle: \"#add_new_episode_relation_button\",\n\t\t\tdeleteHandle: \".episode_relation_remove\",\n\t\t\tonRowLoad: function(o) {\n\t\t\t\to.row = o.row.replace(/\\{\\{id\\}\\}/g, i);\n\t\t\t\t\n\t\t\t\ti++;\n\t\t\t},\n\t\t\tonRowAdd: function(o, init) {\n\t\t\t\tvar row = $(\"#episode_relation_table_body tr:last\");\n\n\t\t\t\tvar new_row_id = row.find('select.podlove_episode_relation_episodes_dropdown').last().attr('id');\n\t\t\t\trow.find('select.podlove_episode_relation_episodes_dropdown option[value=\"' + o.entry.id + '\"]').attr('selected',true);\n\n\t\t\t\tupdate_chosen();\n\n\t\t\t\t// Focus new row\n\t\t\t\tif (!init) {\n\t\t\t\t\t$(\"#\" + new_row_id + \"_chosen\").find(\"a\").focus();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonRowDelete: function(tr) {\n\t\t\t\t\n\t\t\t}\n\t\t});\n\t});\n}(jQuery));"
  },
  {
    "path": "lib/modules/related_episodes/model/episode_relation.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\RelatedEpisodes\\Model;\n\nuse Podlove\\Model\\Base;\nuse Podlove\\Model\\Episode;\n\nclass EpisodeRelation extends Base\n{\n    /**\n     * Get episodes related to the given episode.\n     *\n     * @param bool|int $episode_id\n     * @param array    $args       List of optional arguments.\n     *                             only_published - If true, only return already published episodes. Default: false.\n     *\n     * @return array\n     */\n    public static function get_related_episodes($episode_id = false, $args = [])\n    {\n        global $wpdb;\n\n        $defaults = ['only_published' => false];\n        $args = wp_parse_args($args, $defaults);\n\n        if (!$episode_id) {\n            return [];\n        }\n\n        $filter_post_status = '';\n        if ($args['only_published']) {\n            $filter_post_status = 'AND p.post_status IN (\\'publish\\', \\'private\\')';\n        }\n\n        $sql = sprintf('SELECT\n\t\t\te.*\n\t\t\tFROM\n\t\t\t'.Episode::table_name().' e\n\t\t\tINNER JOIN '.$wpdb->posts.' p ON p.ID = e.post_id\n\t\t\tWHERE e.id IN (\n\t\t\t\tSELECT right_episode_id FROM '.self::table_name().' WHERE left_episode_id = %1$d\n\t\t\t\tUNION\n\t\t\t\tSELECT left_episode_id FROM '.self::table_name().' WHERE right_episode_id = %1$d\n\t\t\t) '.$filter_post_status.'\n\t\t\tORDER BY p.post_date_gmt ASC', $episode_id);\n\n        return Episode::find_all_by_sql($sql);\n    }\n}\n\nEpisodeRelation::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nEpisodeRelation::property('left_episode_id', 'INT');\nEpisodeRelation::property('right_episode_id', 'INT');\n"
  },
  {
    "path": "lib/modules/related_episodes/related_episodes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\RelatedEpisodes;\n\nuse Podlove\\Api\\Episodes\\WP_REST_PodloveEpisodeRelated_Controller;\nuse Podlove\\Model;\nuse Podlove\\Modules\\RelatedEpisodes\\Model\\EpisodeRelation;\n\nclass Related_Episodes extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Related Episodes';\n    protected $module_description = 'Create related pairs of episodes. Display with shortcode <code>[podlove-related-episodes]</code>';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('podlove_module_was_activated_related_episodes', [$this, 'was_activated']);\n        add_filter('podlove_episode_form_data', [$this, 'episode_relation_form'], 10, 2);\n        add_action('rest_api_init', [$this, 'api_init']);\n\n        add_action('admin_print_styles', [$this, 'admin_print_styles']);\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'relatedEpisodes',\n            ['\\Podlove\\Modules\\RelatedEpisodes\\TemplateExtensions', 'accessorRelatedEpisodes'],\n            5\n        );\n\n        add_filter('podlove_twig_file_loader', function ($file_loader) {\n            $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'related_episodes', 'templates']), 'related-episodes');\n\n            return $file_loader;\n        });\n\n        Shortcodes::init();\n    }\n\n    public function uninstall()\n    {\n        EpisodeRelation::destroy();\n    }\n\n    public function was_activated($module_name)\n    {\n        EpisodeRelation::build();\n    }\n\n    public function api_init()\n    {\n        $api_episode_related = new WP_REST_PodloveEpisodeRelated_Controller();\n        $api_episode_related->register_routes();\n    }\n\n    public function episode_relation_form($form_data)\n    {\n        $form_data[] = [\n            'type' => 'callback',\n            'key' => 'episode_relation_form_table',\n            'options' => [\n                'callback' => function () {\n                    ?>\n                        <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n                            <podlove-related-episodes></podlove-related-episodes>\n                        </div>\n                    <?php\n                }\n            ],\n            'position' => 460,\n        ];\n\n        return $form_data;\n    }\n\n    public function episode_relation_form_callback($form_base_name = '_podlove_meta')\n    {\n        $existing_episodes = [];\n        foreach (Model\\Episode::find_all_by_time() as $episode) {\n            $existing_episodes[$episode->id] = get_the_title($episode->post_id);\n        }\n        $episode = Model\\Episode::find_one_by_post_id(get_the_ID());\n\n        $existing_episode_relations = array_map(\n            function ($episode) {\n                return $episode->to_array();\n            },\n            EpisodeRelation::get_related_episodes($episode->id)\n        ); ?>\n\t\t\t<div id=\"episode-relation-form\">\n\t\t\t\t<table class=\"podlove_alternating\" border=\"0\" cellspacing=\"0\">\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th><?php _e('Episode', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t<th><?php _e('Remove', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody id=\"episode_relation_table_body\" style=\"min-height: 50px;\">\n\t\t\t\t\t\t<tr class=\"episode_relation_table_body_placeholder\" style=\"display: none;\">\n\t\t\t\t\t\t\t<td><em><?php echo __('No episode relations were added yet.', 'podlove-podcasting-plugin-for-wordpress'); ?></em></td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\n\t\t\t\t<div id=\"add_new_episode_relation_wrapper\">\n\t\t\t\t\t<input class=\"button\" id=\"add_new_episode_relation_button\" value=\"+\" type=\"button\" />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<script type=\"text/template\" id=\"episode-relation-row-template\">\n\t\t\t<tr class=\"podlove-episode-relation-table\">\n\t\t\t\t<td>\n\t\t\t\t\t<select name=\"<?php echo $form_base_name; ?>[related_episodes][{{id}}]\" id=\"<?php echo $form_base_name; ?>_related_episodes_{{id}}\"  class=\"chosen-related-episodes podlove_episode_relation_episodes_dropdown\">\n\t\t\t\t\t<?php foreach ($existing_episodes as $episode_id => $episode_title) { ?>\n\t\t\t\t\t\t<option value=\"<?php echo $episode_id; ?>\"><?php echo $episode_title; ?></option>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t\t</select>\n\t\t\t\t</td>\n\t\t\t\t<td>\n\t\t\t\t\t<span class=\"episode_relation_remove\">\n\t\t\t\t\t\t<i class=\"clickable podlove-icon-remove\"></i>\n\t\t\t\t\t</span>\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t\t</script>\n\t\t\t<script type=\"text/javascript\">\n\t\t\t\tvar PODLOVE = PODLOVE || {};\n\n\t\t\t\tPODLOVE.related_episodes_existing_episode_relations = <?php echo wp_json_encode($existing_episode_relations); ?>;\n\t\t\t</script>\n\t\t\t<?php\n    }\n\n    public function admin_print_styles()\n    {\n        if (!\\Podlove\\is_episode_edit_screen()) {\n            return;\n        }\n\n        wp_register_script(\n            'podlove_related_episodes',\n            $this->get_module_url().'/js/admin.js',\n            ['jquery', 'podlove_admin'],\n            \\Podlove\\get_plugin_header('Version')\n        );\n        wp_enqueue_script('podlove_related_episodes');\n    }\n}\n"
  },
  {
    "path": "lib/modules/related_episodes/shortcodes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\RelatedEpisodes;\n\nclass Shortcodes\n{\n    public static function init()\n    {\n        add_shortcode('podlove-related-episodes', [__CLASS__, 'related_episodes']);\n    }\n\n    /**\n     * Related Episodes Shortcode.\n     *\n     * @param array  $args    List of arguments. (none supported)\n     * @param string $content Optional shortcode content. If any is set it is inserted before the list. But only if there are entries.\n     *\n     * @return string\n     */\n    public static function related_episodes($args = [], $content = '')\n    {\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@related-episodes/related-episodes-list.twig', ['before' => $content]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/related_episodes/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\RelatedEpisodes;\n\nuse Podlove\\Model;\nuse Podlove\\Modules\\RelatedEpisodes\\Model\\EpisodeRelation;\nuse Podlove\\Template;\n\nclass TemplateExtensions\n{\n    /**\n     * List of Related Episodes.\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.relatedEpisodes\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $episode\n     * @param mixed $post\n     * @param mixed $args\n     */\n    public static function accessorRelatedEpisodes($return, $method_name, $episode, $post, $args = [])\n    {\n        $episodes = [];\n\n        foreach (EpisodeRelation::get_related_episodes($episode->id, ['only_published' => true]) as $related_episode) {\n            $episodes[] = new Template\\Episode(Model\\Episode::find_by_id($related_episode->id));\n        }\n\n        return $episodes;\n    }\n}\n"
  },
  {
    "path": "lib/modules/related_episodes/templates/related-episodes-list.twig",
    "content": "{% set related_episodes = episode.relatedEpisodes %}\n\n{% if related_episodes %}\n\n\t{% if option.before %}\n\t\t{{ option.before }}\n\t{% endif %}\n\n\t{% apply spaceless %}\n\t\t<ul>\n\t\t\t{% for related_episode in related_episodes %}\n\t\t\t\t<li>\n\t\t\t\t\t<a href=\"{{ related_episode.url }}\">{{ related_episode.title }}</a>\n\t\t\t\t</li>\n\t\t\t{% endfor %}\n\t\t</ul>\n\t{% endapply %}\n\n{% endif %}\n"
  },
  {
    "path": "lib/modules/seasons/css/admin.css",
    "content": "/* seasons table */\n\n.seasons .column-number { width: 15px; }\n.seasons .column-image  { width: 75px; }\n.seasons .column-start  { width: 150px; }\n\n@media all and (max-width: 782px) {\n\t.seasons .column-start { display: none; }\n}\n\n/* seasons form */\n\n.row_podlove_season_start_date td > div:before {\n\tfont: 400 20px/1 dashicons;\n\tcontent: '\\f145';\n\ttop: 4px;\n\tposition: relative;\n\tcolor: #82878c;\n\tcursor: pointer;\n}\n\n/* episode form */\n\n.row__podlove_meta_season {\n    position: absolute;\n    right: 15px;\n    top: 0px;\n}\n.row__podlove_meta_season span { font-size: 1.2em }\n"
  },
  {
    "path": "lib/modules/seasons/js/admin.js",
    "content": "jQuery(document).ready(function($) {\n\tvar $start_date = $(\"#podlove_season_start_date\")\n    $start_date.datepicker({\n    \tdateFormat: $.datepicker.ISO_8601,\n\t\tchangeMonth: true,\n\t\tchangeYear: true\n    });\n\n    $start_date.closest(\"div\").on(\"click\", function() {\n    \t$start_date.datepicker(\"show\");\n    });\n});\n"
  },
  {
    "path": "lib/modules/seasons/model/season.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Model;\n\nuse Podlove\\Model\\Base;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Image;\n\nclass Season extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function for_episode(Episode $episode)\n    {\n        return SeasonMap::get()->get_season_for_episode_id($episode->id);\n    }\n\n    public static function by_date($timestamp)\n    {\n        if (!is_numeric($timestamp)) {\n            throw new InvalidArgumentException('Season::by_date expects a timestamp as parameter');\n        }\n\n        $seasons = Season::all();\n        $seasons = array_filter($seasons, function ($season) use ($timestamp) {\n            $start = strtotime(get_post($season->first_episode()->post_id)->post_date);\n            $end = strtotime(get_post($season->last_episode()->post_id)->post_date);\n\n            return $start <= $timestamp && ($end >= $timestamp || $season->is_running());\n        });\n\n        if (count($seasons) > 0) {\n            return reset($seasons);\n        }\n\n        return null;\n    }\n\n    public function title()\n    {\n        if ($this->title) {\n            return $this->title;\n        }\n\n        return __('Season', 'podlove-podcasting-plugin-for-wordpress').' '.$this->number();\n    }\n\n    public function image()\n    {\n        return new Image($this->image, $this->title);\n    }\n\n    /**\n     * First day of the season.\n     *\n     * Season 1 may have an empty start_date.\n     *\n     * @param string $format date format, defaults to WordPress setting\n     *\n     * @return null|string\n     */\n    public function start_date($format = null)\n    {\n        if (is_null($format)) {\n            $format = get_option('date_format');\n        }\n\n        if ($time = strtotime($this->start_date)) {\n            return date($format, $time);\n        }\n\n        return null;\n    }\n\n    public function next_season()\n    {\n        if (!$this->start_date) {\n            return Season::find_one_by_where('start_date IS NOT NULL ORDER BY start_date ASC');\n        }\n\n        return Season::find_one_by_where('start_date > \\''.$this->start_date('Y-m-d').'\\' ORDER BY start_date ASC');\n    }\n\n    public function previous_season()\n    {\n        if (!$this->start_date) {\n            return null;\n        }\n\n        return Season::find_one_by_where(\n            '\n\t\t\t\t\tstart_date < \\''.$this->start_date('Y-m-d').'\\' \n\t\t\t\t\tOR start_date IS NULL \n\t\t\t\tORDER BY\n\t\t\t\t\tstart_date DESC'\n        );\n    }\n\n    /**\n     * Is this season currently running?\n     *\n     * @return bool\n     */\n    public function is_running()\n    {\n        return is_null($this->next_season());\n    }\n\n    public function first_episode()\n    {\n        global $wpdb;\n\n        $previous_season = $this->previous_season();\n\n        if ($previous_season) {\n            $date_condition = \"AND DATE(p.post_date) > '\".$previous_season->end_date('Y-m-d').\"'\";\n        } else {\n            $date_condition = '';\n        }\n\n        $sql = 'SELECT\n\t\t\t\te.*\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.\"` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_type = 'podcast' AND\n\t\t\t\tp.post_status = 'publish' \n\t\t\t\t{$date_condition}\n\t\t\tORDER BY\n\t\t\t\tp.post_date ASC\n\t\t\tLIMIT 0,1\n\t\t\";\n\n        return Episode::find_one_by_sql($sql);\n    }\n\n    public function last_episode()\n    {\n        global $wpdb;\n\n        $next_season = $this->next_season();\n\n        if ($next_season) {\n            $date_condition = \"AND DATE(p.post_date) < '\".$next_season->start_date('Y-m-d').\"'\";\n        } else {\n            $date_condition = '';\n        }\n\n        $sql = 'SELECT\n\t\t\t\te.*\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.\"` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_type = 'podcast' AND\n\t\t\t\tp.post_status = 'publish' \n\t\t\t\t{$date_condition}\n\t\t\tORDER BY\n\t\t\t\tp.post_date DESC\n\t\t\tLIMIT 0,1\n\t\t\";\n\n        return Episode::find_one_by_sql($sql);\n    }\n\n    /**\n     * Last day of the season.\n     *\n     * The current season has no end_date.\n     * Otherwise the end date equals the publication date of the last episode\n     * in that season.\n     *\n     * @param string $format date format, defaults to WordPress setting\n     *\n     * @return null|string\n     */\n    public function end_date($format = null)\n    {\n        global $wpdb;\n\n        if ($this->is_running()) {\n            return null;\n        }\n\n        if (is_null($format)) {\n            $format = get_option('date_format');\n        }\n\n        $episode = $this->last_episode();\n\n        if (is_null($episode)) {\n            return null;\n        }\n\n        return get_the_date($format, $episode->post_id);\n    }\n\n    public function episodes($args = [])\n    {\n        global $wpdb;\n\n        $prev = $this->previous_season();\n        $next = $this->next_season();\n\n        if (is_null($prev) && is_null($next)) { // first and only season\n            $date_range = '1 = 1';\n        } elseif (is_null($prev)) { // first, completed season\n            $date_range = \"DATE(p.post_date) < '\".$next->start_date('Y-m-d').\"'\";\n        } elseif (is_null($next)) { // current running season\n            $date_range = \"DATE(p.post_date) > '\".$prev->end_date('Y-m-d').\"'\";\n        } else { // anything inbetween\n            $date_range = \"DATE(p.post_date) >= '\".$this->start_date('Y-m-d').\"' AND DATE(p.post_date) <= '\".$this->end_date('Y-m-d').\"'\";\n        }\n\n        $order = isset($args['order']) && strtoupper($args['order']) == 'DESC' ? 'DESC' : 'ASC';\n\n        $sql = 'SELECT\n\t\t\t\te.*\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.\"` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_type = 'podcast' AND\n\t\t\t\tp.post_status = 'publish' AND\n\t\t\t\t{$date_range}\n\t\t\tORDER BY\n\t\t\t\tp.post_date {$order}\n\t\t\";\n\n        return Episode::find_all_by_sql($sql);\n    }\n\n    /**\n     * Season number.\n     *\n     * Automatically determined season number.\n     *\n     * One season may have no start date and is assumed to be the first season.\n     *\n     * @return int\n     */\n    public function number()\n    {\n        $seasons = Season::find_all_by_where(\"start_date < '\".$this->start_date.\"' OR start_date IS NULL AND id != \".$this->id);\n\n        return count($seasons) + 1;\n    }\n}\n\nSeason::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nSeason::property('title', 'VARCHAR(255)');\nSeason::property('subtitle', 'TEXT');\nSeason::property('summary', 'TEXT');\nSeason::property('image', 'TEXT');\nSeason::property('start_date', 'DATE');\n"
  },
  {
    "path": "lib/modules/seasons/model/season_map.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Model;\n\nuse Podlove\\Model\\Episode;\n\n/**\n * Maps seasons to episode IDs.\n *\n * Solves \"n+1\" problem in, for example, feeds.\n * Instead of looking up the season for every item, fetch them all once and keep\n * the information in memory.\n *\n * Singleton\n */\nclass SeasonMap\n{\n    /**\n     * Contains property values.\n     *\n     * @var array\n     */\n    private $data = [];\n\n    protected function __construct()\n    {\n        $this->init();\n    }\n\n    private function __clone() {}\n\n    public static function get()\n    {\n        static $instance = null;\n        if ($instance === null) {\n            $instance = new self();\n        }\n\n        return $instance;\n    }\n\n    public function get_season_for_episode_id($episode_id)\n    {\n        foreach ($this->data as $season_id => $episode_ids) {\n            if (in_array($episode_id, $episode_ids)) {\n                return Season::find_by_id($season_id);\n            }\n        }\n\n        return null;\n    }\n\n    private function init()\n    {\n        global $wpdb;\n\n        $sql = 'select * from '.Season::table_name().' s order by start_date ASC';\n        $seasons = Season::find_all_by_sql($sql);\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t\te.id, p.post_date\n\t\t\tFROM\n\t\t\t\t`'.Episode::table_name().'` e\n\t\t\t\tJOIN `'.$wpdb->posts.'` p ON e.post_id = p.ID\n\t\t\tWHERE\n\t\t\t\tp.post_type = \"podcast\"\n\t\t\tORDER BY\n\t\t\t\tp.post_date DESC';\n        $episodes = $wpdb->get_results($sql);\n\n        $groups = [];\n        foreach ($seasons as $season) {\n            $id = $season->id;\n            $is_running = $season->is_running();\n            $groups[$id] = [];\n\n            $first_episode = $season->first_episode();\n            $last_episode = $season->last_episode();\n\n            if ($first_episode && $last_episode) {\n                $start = strtotime(get_post($first_episode->post_id)->post_date);\n                $end = strtotime(get_post($last_episode->post_id)->post_date);\n\n                foreach ($episodes as $episode) {\n                    $timestamp = strtotime($episode->post_date);\n                    if ($start <= $timestamp && ($end >= $timestamp || $is_running)) {\n                        $groups[$id][] = (int) $episode->id;\n                    }\n                }\n            }\n        }\n\n        $this->data = $groups;\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/model/seasons_issue.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Model;\n\nclass SeasonsIssue\n{\n    public $type;\n\n    public function message()\n    {\n        switch ($this->type) {\n            case 'multiple_first_seasons':\n                return __('Only one season can have an empty start date.', 'podlove-podcasting-plugin-for-wordpress');\n\n                break;\n            case 'duplicate_start_dates':\n                return __('Some of your seasons have the same start date.', 'podlove-podcasting-plugin-for-wordpress');\n\n                break;\n\n            default:\n                return __('Unknown seasons issue.', 'podlove-podcasting-plugin-for-wordpress');\n\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/model/seasons_validator.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Model;\n\nclass SeasonsValidator\n{\n    private $issues;\n\n    public function __construct()\n    {\n        $this->issues = [];\n    }\n\n    public function validate()\n    {\n        $this->checkForMultipleFirstSeasons();\n        $this->checkForDuplicateStartDates();\n    }\n\n    public function issues()\n    {\n        return $this->issues;\n    }\n\n    public function checkForDuplicateStartDates()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t  COUNT(*) cnt\n\t\t\tFROM\n\t\t\t  `'.Season::table_name().'` s\n\t\t\tWHERE\n\t\t\t  start_date IS NOT NULL\n\t\t\tGROUP BY\n\t\t\t  start_date\n\t\t\tHAVING\n\t\t\t  cnt > 1\n\t\t';\n\n        if ($wpdb->get_var($sql)) {\n            $issue = new SeasonsIssue();\n            $issue->type = 'duplicate_start_dates';\n            $this->issues[] = $issue;\n        }\n    }\n\n    private function checkForMultipleFirstSeasons()\n    {\n        $seasons = Season::find_all_by_where('start_date IS NULL');\n\n        if (count($seasons) > 1) {\n            $issue = new SeasonsIssue();\n            $issue->type = 'multiple_first_seasons';\n            $this->issues[] = $issue;\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/podcast_import_seasons_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportSeasonsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Seasons';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Seasons';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Seasons\\Model\\Season';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'season';\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/seasons.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Seasons\\Model\\Season;\n\nclass Seasons extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Seasons';\n    protected $module_description = 'Group your episodes into seasons.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        // module lifecycle\n        add_action('podlove_module_was_activated_seasons', [$this, 'was_activated']);\n\n        // register settings page\n        add_action('podlove_register_settings_pages', function ($handle) {\n            new \\Podlove\\Modules\\Seasons\\Settings\\Settings($handle);\n        });\n\n        add_action('admin_print_styles', [$this, 'scripts_and_styles']);\n\n        add_action('podlove_xml_export', [$this, 'expandExportFile']);\n        add_filter('podlove_import_jobs', [$this, 'expandImport']);\n\n        add_filter('set-screen-option', function ($status, $option, $value) {\n            if ($option == 'podlove_seasons_per_page') {\n                return $value;\n            }\n\n            return $status;\n        }, 10, 3);\n\n        add_action('podlove_append_to_feed_entry', [$this, 'add_season_number_to_feed'], 10, 4);\n\n        add_filter('podlove_episode_form_data', [$this, 'add_season_number_to_episode_form'], 10, 2);\n        add_filter('podlove_generated_post_title', [$this, 'set_season_in_post_title'], 10, 2);\n        add_filter('podlove_js_data_for_post_title', [$this, 'set_season_in_post_title_js'], 10, 2);\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'seasons',\n            ['\\Podlove\\Modules\\Seasons\\TemplateExtensions', 'accessorPodcastSeasons'],\n            4\n        );\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'season',\n            ['\\Podlove\\Modules\\Seasons\\TemplateExtensions', 'accessorEpisodeSeason'],\n            4\n        );\n    }\n\n    public function was_activated($module_name)\n    {\n        Season::build();\n    }\n\n    public function uninstall()\n    {\n        Season::destroy();\n    }\n\n    public function scripts_and_styles()\n    {\n        $is_seasons_settings_page = filter_input(INPUT_GET, 'page') === 'podlove_seasons_settings';\n\n        if (!$is_seasons_settings_page && !\\Podlove\\is_episode_edit_screen()) {\n            return;\n        }\n\n        wp_enqueue_style(\n            'podlove_seasons_admin_style',\n            \\Podlove\\PLUGIN_URL.'/lib/modules/seasons/css/admin.css',\n            false,\n            \\Podlove\\get_plugin_header('Version')\n        );\n\n        wp_enqueue_script(\n            'podlove_seasons_admin_script',\n            $this->get_module_url().'/js/admin.js',\n            ['jquery'],\n            \\Podlove\\get_plugin_header('Version')\n        );\n    }\n\n    public function add_season_number_to_feed($podcast, $episode, $feed, $format)\n    {\n        $season = Season::for_episode($episode);\n\n        if (!$season) {\n            return;\n        }\n\n        $number = $season->number();\n\n        echo sprintf(\"\\n\\t\\t<itunes:season>%d</itunes:season>\", $number);\n    }\n\n    /**\n     * Expands \"Import/Export\" module: export logic.\n     */\n    public function expandExportFile(\\SimpleXMLElement $xml)\n    {\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'seasons', 'season', '\\Podlove\\Modules\\Seasons\\Model\\Season');\n    }\n\n    /**\n     * Expands \"Import/Export\" module: import logic.\n     *\n     * @param mixed $jobs\n     */\n    public function expandImport($jobs)\n    {\n        $jobs[] = '\\Podlove\\Modules\\Seasons\\PodcastImportSeasonsJob';\n\n        return $jobs;\n    }\n\n    public function add_season_number_to_episode_form($form_data, $episode)\n    {\n        $season = Season::for_episode($episode);\n\n        if (!$season) {\n            return $form_data;\n        }\n\n        $title = __('Season', 'podlove-podcasting-plugin-for-wordpress').' '.$season->number();\n\n        $entry = [\n            'type' => 'callback',\n            'key' => 'season',\n            'options' => [\n                'callback' => function () use ($title) {\n                    ?>\n\t\t\t\t\t<span><?php echo $title; ?></span>\n\t\t\t\t\t<?php\n                },\n            ],\n            'position' => 1250,\n        ];\n\n        $form_data[] = $entry;\n\n        return $form_data;\n    }\n\n    public function set_season_in_post_title($title, $episode)\n    {\n        return str_replace(\n            '%season_number%',\n            self::get_printable_season_number($episode),\n            $title\n        );\n    }\n\n    public function set_season_in_post_title_js($data, $post_id)\n    {\n        $episode = Episode::find_one_by_property('post_id', $post_id);\n        $data['season_number'] = self::get_printable_season_number($episode);\n\n        return $data;\n    }\n\n    public static function get_printable_season_number($episode)\n    {\n        if ($episode && ($season = Season::for_episode($episode))) {\n            return $season->number();\n        }\n\n        return '??';\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/settings/help/settings.php",
    "content": "<?php\n\nreturn [\n    'podlove_help_seasons_title' => [\n        'title' => __('Season Title', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'.__('Title of the season.', 'podlove-podcasting-plugin-for-wordpress').'</p>'\n                   .'<p>'.__('If you leave the title empty, seasons will be named automatically. \"Season 1\", \"Season 2\" etc.', 'podlove-podcasting-plugin-for-wordpress').'</p>',\n    ],\n    'podlove_help_seasons_date' => [\n        'title' => __('Season Start & End', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'.__('Start date of the season.', 'podlove-podcasting-plugin-for-wordpress').'</p>'\n                   .'<p>'.__('Each season has a start date. End dates are determined automatically. The first season does not need a start date.', 'podlove-podcasting-plugin-for-wordpress').'</p>',\n    ],\n];\n"
  },
  {
    "path": "lib/modules/seasons/settings/season_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Settings;\n\nuse Podlove\\Modules\\Seasons\\Model\\Season;\n\nclass SeasonListTable extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        parent::__construct([\n            'singular' => 'season',   // singular name of the listed records\n            'plural' => 'seasons',  // plural name of the listed records\n            'ajax' => false,       // does this table support ajax?\n        ]);\n    }\n\n    public function column_season_title($season)\n    {\n        $link = function ($title, $action = 'edit') use ($season) {\n            return sprintf(\n                '<a href=\"?page=%s&action=%s&season=%s&_podlove_nonce=%s\">'.$title.'</a>',\n                Settings::MENU_SLUG,\n                $action,\n                $season->id,\n                wp_create_nonce('update_seasons')\n            );\n        };\n\n        $actions = [\n            'edit' => $link(__('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => $link(__('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            $link($season->title()),\n            $this->row_actions($actions)\n        );\n    }\n\n    public function column_number($season)\n    {\n        return $season->number();\n    }\n\n    public function column_start($season)\n    {\n        return $season->start_date();\n    }\n\n    public function column_image($season)\n    {\n        if ($season->image) {\n            return $season->image()->setWidth(64)->setHeight(64)->image();\n        }\n\n        return '';\n    }\n\n    public function column_episodes($season)\n    {\n        $episodes = $season->episodes();\n\n        $count = count($episodes);\n        $first = reset($episodes);\n        $last = end($episodes);\n\n        if (!$count) {\n            return '-';\n        }\n\n        $totals = function ($count) {\n            return '<br><span style=\"font-size: 1.6em; vertical-align: middle; padding: 5px 10px; display: inline-block;\">&#x2193;</span> <small>'.__('total', 'podlove-podcasting-plugin-for-wordpress').': '.$count.' episodes</small><br>';\n        };\n\n        $link = function ($episode) {\n            return '<a href=\"'.get_edit_post_link($episode->post_id).'\">'.$episode->title().'</a> <small>'.get_the_date('', $episode->post_id).'</small>';\n        };\n\n        return $link($first).$totals($count).$link($last);\n    }\n\n    public function get_columns()\n    {\n        return [\n            'number' => __('#', 'podlove-podcasting-plugin-for-wordpress'),\n            'image' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n            'season_title' => __('Season', 'podlove-podcasting-plugin-for-wordpress'),\n            'episodes' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'start' => __('Start', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = get_user_meta(get_current_user_id(), 'podlove_seasons_per_page', true);\n        if (empty($per_page)) {\n            $per_page = 10;\n        }\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        $data = Season::find_all_by_where('1 = 1 ORDER BY start_date ASC');\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/settings/settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Settings;\n\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Seasons\\Model\\Season;\nuse Podlove\\Modules\\Seasons\\Model\\SeasonsValidator;\n\nclass Settings\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public const MENU_SLUG = 'podlove_seasons_settings';\n\n    private static $nonce = 'update_seasons';\n\n    public function __construct($handle)\n    {\n        $pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Seasons', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Seasons', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation($pagehook);\n\n        add_action('admin_init', [$this, 'process_form']);\n        add_action('load-'.$pagehook, [$this, 'add_screen_options']);\n    }\n\n    public function add_screen_options()\n    {\n        add_screen_option('per_page', [\n            'label' => 'Seasons',\n            'default' => 10,\n            'option' => 'podlove_seasons_per_page',\n        ]);\n\n        $this->table = new SeasonListTable();\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['season'])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        }\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Seasons', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=new\" class=\"add-new-h2\"><?php echo __('Add New', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\t\t\t<?php\n            $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n        switch ($action) {\n            case 'new':   $this->new_template();\n\n                break;\n            case 'edit':  $this->edit_template();\n\n                break;\n            case 'index': $this->view_template();\n\n                break;\n\n            default:      $this->view_template();\n\n                break;\n        } ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    /**\n     * Process form: save/update a format.\n     */\n    private function save()\n    {\n        if (!isset($_REQUEST['season'])) {\n            return;\n        }\n\n        $season = Season::find_by_id($_REQUEST['season']);\n        $season->update_attributes($_POST['podlove_season']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $season->id);\n        } else {\n            $this->redirect('index', $season->id);\n        }\n    }\n\n    /**\n     * Process form: create a format.\n     */\n    private function create()\n    {\n        global $wpdb;\n\n        $season = new Season();\n        $season->update_attributes($_POST['podlove_season']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $season->id);\n        } else {\n            $this->redirect('index');\n        }\n    }\n\n    /**\n     * Process form: delete a format.\n     */\n    private function delete()\n    {\n        if (!isset($_REQUEST['season'])) {\n            return;\n        }\n\n        if ($season = Season::find_by_id($_REQUEST['season'])) {\n            $season->delete();\n        }\n\n        $this->redirect('index');\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $episode_asset_id\n     * @param mixed      $params\n     */\n    private function redirect($action, $episode_asset_id = null, $params = [])\n    {\n        $page = 'admin.php?page='.self::MENU_SLUG;\n        $show = ($episode_asset_id) ? '&season='.$episode_asset_id : '';\n        $action = '&action='.$action;\n\n        array_walk($params, function (&$value, $key) {\n            $value = \"&{$key}={$value}\";\n        });\n\n        wp_redirect(admin_url($page.$show.$action.implode('', $params)));\n        exit;\n    }\n\n    private function new_template()\n    {\n        $season = new Season(); ?>\n\t\t<h3><?php echo __('Add New Season', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<?php\n        $this->form_template($season, 'create', __('Add New Season', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    private function view_template()\n    {\n        $validator = new SeasonsValidator();\n        $validator->validate();\n        $issues = $validator->issues();\n        foreach ($validator->issues() as $issue) {\n            ?>\n\t\t\t<div class=\"error\">\n\t\t\t\t<p>\n\t\t\t\t\t<strong><?php echo __('Warning', 'podlove-podcasting-plugin-for-wordpress').': '; ?></strong>\n\t\t\t\t\t<?php echo $issue->message(); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        }\n\n        $this->table->prepare_items();\n        $this->table->display();\n    }\n\n    private function form_template($season, $action, $button_text = null)\n    {\n        wp_enqueue_script('jquery');\n        wp_enqueue_script('jquery-ui-core');\n        wp_enqueue_script('jquery-ui-datepicker');\n\n        $form_args = [\n            'context' => 'podlove_season',\n            'hidden' => [\n                'season' => $season->id,\n                'action' => $action,\n            ],\n            'submit_button' => false, // for custom control in form_end\n            'form_end' => function () {\n                echo '<p>';\n                submit_button(__('Save Changes'), 'primary', 'submit', false);\n                echo ' ';\n                submit_button(__('Save Changes and Continue Editing', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit_and_stay', false);\n                echo '</p>';\n            },\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($season, $form_args, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n            $podcast = Podcast::get();\n\n            $wrapper->string('title', [\n                'label' => __('Title', 'podlove-podcasting-plugin-for-wordpress').\\Podlove\\get_help_link('podlove_help_seasons_title'),\n                'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'class' => 'regular-text podlove-check-input',\n                    'placeholder' => $podcast->title,\n                ],\n            ]);\n\n            $wrapper->string('subtitle', [\n                'label' => __('Subtitle', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'class' => 'regular-text podlove-check-input',\n                    'placeholder' => $podcast->subtitle,\n                ],\n            ]);\n\n            $wrapper->text('summary', [\n                'label' => __('Summary', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'rows' => 3,\n                    'cols' => 40,\n                    'class' => 'podlove-check-input',\n                    'placeholder' => $podcast->summary,\n                ],\n            ]);\n\n            $wrapper->string('start_date', [\n                'label' => __('Start Date', 'podlove-podcasting-plugin-for-wordpress').\\Podlove\\get_help_link('podlove_help_seasons_date'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'readonly' => 'readonly'],\n            ]);\n\n            $wrapper->upload('image', [\n                'label' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n                'media_button_text' => __('Use Image for Season', 'podlove-podcasting-plugin-for-wordpress'),\n            ]);\n        });\n    }\n\n    private function edit_template()\n    {\n        $season = Season::find_by_id($_REQUEST['season']);\n        echo '<h3>'.sprintf(__('Edit Season: %s', 'podlove-podcasting-plugin-for-wordpress'), $season->title).'</h3>';\n        $this->form_template($season, 'save');\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/template/season.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Season Template Wrapper.\n *\n * @templatetag season\n */\nclass Season extends Wrapper\n{\n    private $season;\n\n    public function __construct(\\Podlove\\Modules\\Seasons\\Model\\Season $season)\n    {\n        $this->season = $season;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->season->title();\n    }\n\n    /**\n     * Subtitle.\n     *\n     * @accessor\n     */\n    public function subtitle()\n    {\n        return $this->season->subtitle;\n    }\n\n    /**\n     * Summary.\n     *\n     * @accessor\n     */\n    public function summary()\n    {\n        return $this->season->summary;\n    }\n\n    /**\n     * Automatically assigned season number, starting at 1.\n     *\n     * @accessor\n     */\n    public function number()\n    {\n        return $this->season->number();\n    }\n\n    /**\n     * Image.\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return new \\Podlove\\Template\\Image($this->season->image());\n    }\n\n    /**\n     * Start Date.\n     *\n     * This is the configured start date, not the date of the first episode of the season.\n     * If you were looking for that, use `season.firstEpisode.publicationDate`.\n     *\n     * @see  datetime\n     *\n     * @accessor\n     */\n    public function startDate()\n    {\n        return new \\Podlove\\Template\\DateTime(strtotime($this->season->start_date));\n    }\n\n    /**\n     * First episode of the season.\n     *\n     * @see  episode\n     *\n     * @accessor\n     */\n    public function firstEpisode()\n    {\n        return new \\Podlove\\Template\\Episode($this->season->first_episode());\n    }\n\n    /**\n     * Last episode of the season.\n     *\n     * @see  episode\n     *\n     * @accessor\n     */\n    public function lastEpisode()\n    {\n        return new \\Podlove\\Template\\Episode($this->season->last_episode());\n    }\n\n    /**\n     * Is this season currently running?\n     *\n     * ```jinja\n     * {% if season.running %}\n     *     This season is currently running.\n     * {% endif %}\n     * ```\n     *\n     * @accessor\n     */\n    public function running()\n    {\n        return $this->season->is_running();\n    }\n\n    /**\n     * Season Episodes.\n     *\n     * Parameters:\n     *\n     * - **order:** (optional) \"DESC\" or \"ASC\". Default: \"ASC\"\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        return array_map(function ($episode) {\n            return new \\Podlove\\Template\\Episode($episode);\n        }, $this->season->episodes($args));\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->season];\n    }\n}\n"
  },
  {
    "path": "lib/modules/seasons/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Seasons;\n\nuse Podlove\\Modules\\Seasons\\Model\\Season;\n\nclass TemplateExtensions\n{\n    /**\n     * List of podcast seasons.\n     *\n     * Parameters:\n     *\n     * - **order:** (optional) \"DESC\" or \"ASC\". Default: \"ASC\"\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.seasons\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $podcast\n     * @param mixed $args\n     */\n    public static function accessorPodcastSeasons($return, $method_name, $podcast, $args = [])\n    {\n        return $podcast->with_blog_scope(function () use ($args) {\n            $order = isset($args['order']) && strtoupper($args['order']) == 'DESC' ? 'DESC' : 'ASC';\n            $seasons = Season::find_all_by_where(\"1 = 1 ORDER BY start_date {$order}\");\n\n            return array_map(function ($season) {\n                return new Template\\Season($season);\n            }, $seasons);\n        });\n    }\n\n    /**\n     * Get season for an episode.\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.season\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $episode\n     * @param mixed $post\n     * @param mixed $args\n     */\n    public static function accessorEpisodeSeason($return, $method_name, $episode, $post, $args = [])\n    {\n        $season = Model\\Season::for_episode($episode);\n\n        if ($season === null) {\n            return new \\stdClass();\n        }\n\n        return new Template\\Season($season);\n    }\n}\n"
  },
  {
    "path": "lib/modules/shownotes/model/entry.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shownotes\\Model;\n\nuse Podlove\\Model\\Base;\n\nclass Entry extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    /**\n     * Prepare Icon.\n     *\n     * If possible, serve icon locally.\n     */\n    public function prepare_icon()\n    {\n        $services = \\Podlove\\Modules\\Social\\Social::services_config();\n        $host = wp_parse_url($this->site_url, PHP_URL_HOST);\n        $icons = array_filter($services, function ($service) use ($host) {\n            return stristr($service['url_scheme'], $host) !== false;\n        });\n\n        if (!$icons) {\n            return;\n        }\n\n        $icon = reset($icons);\n        $service = \\Podlove\\Modules\\Social\\Model\\Service::from_data($icon);\n        $url = $service->image()->url();\n\n        if ($url) {\n            $this->icon = $url;\n        }\n    }\n\n    public static function get_new_position_for_episode($episode_id)\n    {\n        global $wpdb;\n        $table_name = static::table_name();\n\n        $sql = <<<SQL\n            SELECT\n                MAX(e.position)\n            FROM\n                {$table_name} e\n            WHERE\n                e.episode_id = %d\n            GROUP BY\n                e.episode_id\nSQL;\n\n        $position = $wpdb->get_var($wpdb->prepare($sql, $episode_id));\n\n        if (is_numeric($position)) {\n            return $position + 1;\n        }\n\n        return 0;\n    }\n\n    public static function has_shownotes($episode_id)\n    {\n        global $wpdb;\n        $table_name = static::table_name();\n\n        $sql = <<<SQL\n            SELECT\n                COUNT(e.id)\n            FROM\n                {$table_name} e\n            WHERE\n                e.episode_id = %d\nSQL;\n\n        $count = $wpdb->get_var($wpdb->prepare($sql, $episode_id));\n\n        return $count > 0;\n    }\n}\n\nEntry::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nEntry::property('episode_id', 'INT');\nEntry::property('type', 'VARCHAR(255)');\nEntry::property('state', 'VARCHAR(255)');\nEntry::property('position', 'FLOAT');\nEntry::property('unfurl_data', 'TEXT');\nEntry::property('original_url', 'TEXT');\nEntry::property('affiliate_url', 'TEXT'); // virtual?\nEntry::property('url', 'TEXT');\nEntry::property('title', 'TEXT');\nEntry::property('description', 'TEXT');\nEntry::property('site_name', 'TEXT');\nEntry::property('site_url', 'TEXT');\nEntry::property('icon', 'TEXT');\nEntry::property('image', 'TEXT');\nEntry::property('created_at', 'INT');\nEntry::property('hidden', 'INT');\n"
  },
  {
    "path": "lib/modules/shownotes/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shownotes;\n\nuse Podlove\\Http\\Curl;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Shownotes\\Model\\Entry;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v1';\n    public const api_base = 'shownotes';\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'permission_check'],\n                'args' => [\n                    'episode_id' => [\n                        'description' => 'Limit result set by episode.',\n                        'type' => 'integer',\n                    ],\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the object.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)/unfurl', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the object.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'unfurl_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/osf', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'import_osf'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/html', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'import_html'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/render/html', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'render_html'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n    }\n\n    public function render_html($request)\n    {\n        global $post;\n\n        $post_id = $request['post_id'];\n\n        if (!$episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return new \\WP_Error(\n                'podlove_rest_html_no_episode',\n                'episode cannot be found',\n                ['status' => 400]\n            );\n        }\n\n        $post = get_post($episode->post_id);\n        \\setup_postdata($post);\n        $html = \\Podlove\\Template\\TwigFilter::apply_to_html('@shownotes/plain-html-list-grouped.twig');\n        wp_reset_postdata();\n\n        $prettify = function ($html) {\n            $indenter = new \\Gajus\\Dindent\\Indenter();\n\n            return $indenter->indent($html);\n        };\n\n        return rest_ensure_response($prettify($html));\n    }\n\n    public function import_html($request)\n    {\n        $post_id = $request['post_id'];\n\n        if (!$episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return new \\WP_Error(\n                'podlove_rest_html_no_episode',\n                'episode cannot be found',\n                ['status' => 400]\n            );\n        }\n\n        $html = $request['html'] ?? get_the_content(null, false, $post_id);\n\n        $dom = new \\DOMDocument('1.0');\n        $dom->preserveWhiteSpace = false;\n\n        // load html and ensure utf-8\n        // @see php DOMDocument::loadHTML doc comments\n        $valid = $dom->loadHTML('<?xml encoding=\"UTF-8\">'.$html);\n\n        foreach ($dom->childNodes as $item) {\n            if ($item->nodeType == XML_PI_NODE) {\n                $dom->removeChild($item);\n            }\n        }\n\n        $dom->encoding = 'UTF-8';\n\n        if (!$valid) {\n            return new \\WP_Error(\n                'podlove_rest_html_unreadable',\n                'html could not be parsed',\n                ['status' => 400]\n            );\n        }\n\n        $xpath = new \\DOMXPath($dom);\n\n        foreach ($xpath->query('//a | //h1 | //h2 | //h3 | //h4 | //h5 | //h6') as $element) {\n            if ($element->tagName == 'a') {\n                $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'original_url' => $element->getAttribute('href'),\n                    'data' => [\n                        'title' => $element->textContent,\n                    ],\n                    'type' => 'link',\n                ]);\n                rest_do_request($request);\n            } else {\n                $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'data' => [\n                        'title' => $element->textContent,\n                    ],\n                    'title' => $element->textContent,\n                    'type' => 'topic',\n                ]);\n                rest_do_request($request);\n            }\n        }\n\n        return rest_ensure_response(['message' => 'ok']);\n    }\n\n    public function import_osf($request)\n    {\n        $post_id = $request['post_id'];\n\n        if (!function_exists('osf_parser')) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_function',\n                'function \"osf_parser\" is not available',\n                ['status' => 400]\n            );\n        }\n\n        $shownotes = get_post_meta($post_id, '_shownotes', true);\n\n        $tags = explode(' ', 'chapter section spoiler topic embed video audio image shopping glossary source app title quote link podcast news');\n        $data = [\n            'amazon' => '',\n            'thomann' => '',\n            'tradedoubler' => '',\n            'fullmode' => 'true', // sic\n            'tagsmode' => 1,\n            'tags' => $tags,\n        ];\n        $parsed = osf_parser($shownotes, $data);\n\n        $links = [];\n\n        foreach ($parsed['export'] as $group) {\n            if ($group['chapter']) {\n                $links[] = [\n                    'type' => 'topic',\n                    'title' => $group['orig']\n                ];\n            }\n            foreach ($group['subitems'] as $link) {\n                $link['type'] = 'link';\n                $links[] = $link;\n            }\n        }\n\n        if (!is_array($links)) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_links',\n                'there are no osf shownotes or links in them',\n                ['status' => 400]\n            );\n        }\n\n        $links = array_map(function ($link) {\n            if ($link['type'] == 'link') {\n                if (!$link['orig'] || !$link['urls'] || !count($link['urls'])) {\n                    return null;\n                }\n\n                return [\n                    'type' => 'link',\n                    'title' => $link['orig'],\n                    'url' => $link['urls'][0],\n                ];\n            }\n\n            return $link;\n        }, $links);\n        $links = array_filter($links);\n\n        if (!$episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_episode',\n                'episode cannot be found',\n                ['status' => 400]\n            );\n        }\n\n        foreach ($links as $link) {\n            $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n            if ($link['type'] == 'link') {\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'original_url' => $link['url'],\n                    'data' => [\n                        'title' => $link['title'],\n                    ],\n                    'type' => 'link',\n                ]);\n            } else {\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'data' => [\n                        'title' => $link['title'],\n                    ],\n                    'title' => $link['title'],\n                    'type' => 'topic',\n                ]);\n            }\n            rest_do_request($request);\n        }\n\n        return rest_ensure_response(['message' => 'ok']);\n    }\n\n    public function get_items($request)\n    {\n        $episode_id = $request['episode_id'];\n\n        if (!$episode_id) {\n            return new \\WP_Error(\n                'podlove_rest_missing_episode_id',\n                'episode_id is required',\n                ['status' => 400]\n            );\n        }\n\n        $entries = Entry::find_all_by_property('episode_id', $episode_id);\n        $entries = array_map(function ($entry) {\n            $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n            return $entry->to_array();\n        }, $entries);\n\n        return rest_ensure_response($entries);\n    }\n\n    public function create_item($request)\n    {\n        if (!$request['episode_id']) {\n            return new \\WP_Error(\n                'podlove_rest_missing_episode_id',\n                'episode_id is required',\n                ['status' => 400]\n            );\n        }\n\n        $episode = Episode::find_by_id($request['episode_id']);\n\n        if (!$episode) {\n            return new \\WP_Error(\n                'podlove_rest_episode_not_found',\n                'episode does not exist',\n                ['status' => 400]\n            );\n        }\n\n        if ($request['type'] == 'link') {\n            return $this->create_link_item($request, $episode);\n        }\n        if ($request['type'] == 'topic') {\n            return $this->create_topic_item($request, $episode);\n        }\n    }\n\n    public function get_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function delete_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n        $response = rest_ensure_response(['deleted' => true]);\n\n        if (!$entry) {\n            return new \\WP_Error('podlove_rest_already_deleted', 'The entry has already been deleted.', ['status' => 410]);\n        }\n\n        $success = $entry->delete();\n\n        if (!$success) {\n            return new \\WP_Error('podlove_rest_cannot_delete', 'The entry cannot be deleted.', ['status' => 500]);\n        }\n\n        return $response;\n    }\n\n    public function unfurl_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        $url = $entry->original_url;\n\n        $unfurl_endpoint = 'https://plus.podlove.org/api/unfurl';\n        $curl = new Curl();\n        $curl->request(add_query_arg('url', urlencode($url), $unfurl_endpoint), [\n            'headers' => ['Content-type' => 'application/json'],\n            'timeout' => 20,\n        ]);\n\n        $response = $curl->get_response();\n\n        if (is_wp_error($response)) {\n            $entry->state = 'failed';\n            $entry->save();\n\n            $reason = $response->get_error_message();\n\n            return new \\WP_Error(\n                'podlove_rest_unfurl_failed',\n                'error when unfurling entry ('.print_r($reason, true).')',\n                ['status' => 404]\n            );\n        }\n\n        if (!$curl->isSuccessful()) {\n            $entry->state = 'failed';\n            $entry->save();\n\n            $body = json_decode($response['body'], true);\n            $reason = $body['error']['reason'] ?? 'unknown reason';\n\n            return new \\WP_Error(\n                'podlove_rest_unfurl_failed',\n                'error when unfurling entry ('.print_r($reason, true).')',\n                ['status' => 404]\n            );\n        }\n\n        $data = json_decode(\\Podlove\\maybe_encode_emoji($response['body']), true);\n\n        // remove \"data:...\" images because they are too huge to store in database\n        $url_size_threshold = 1000;\n\n        if (isset($data['icon']) && strlen($data['icon']['url']) > $url_size_threshold) {\n            unset($data['icon']);\n        }\n\n        foreach ($data['providers']['misc']['icons'] as $index => $icon) {\n            if (strlen($icon['url']) > $url_size_threshold) {\n                unset($data['providers']['misc']['icons'][$index]);\n            }\n        }\n\n        $entry->unfurl_data = $data;\n        $entry->state = 'fetched';\n        $entry->url = $data['url'];\n        $entry->icon = $data['icon']['url'] ?? '';\n        $entry->image = $data['image'];\n\n        // todo: should probably do this in an async job\n        $attachment_id = 0;\n\n        if ($data['image']) {\n            $attachment_id = \\Podlove\\download_external_image_to_media($data['image'], explode('?', basename($data['image']))[0]);\n        }\n\n        if (!$attachment_id && $data['screenshot_url']) {\n            if (\\Podlove\\Modules\\Base::is_active('plus')) {\n                $plus = \\Podlove\\Modules\\Plus\\Plus::instance();\n                $curl_args = [\n                    'headers' => [\n                        'Authorization' => 'Bearer '.$plus->get_module_option('plus_api_token')\n                    ]\n                ];\n                $attachment_id = \\Podlove\\download_external_image_to_media($data['screenshot_url'], 'screenshot.jpg', $curl_args);\n            }\n        }\n\n        if ($attachment_id && !\\is_wp_error($attachment_id)) {\n            $attachment_url = \\wp_get_attachment_url($attachment_id);\n            $entry->image = $attachment_url;\n        }\n\n        if (!$entry->title) {\n            $entry->title = $data['title'];\n        }\n\n        if (!$entry->description) {\n            $entry->description = $data['description'];\n        }\n\n        if (!$entry->site_name) {\n            $entry->site_name = $data['site_name'];\n        }\n\n        if (!$entry->site_url) {\n            $entry->site_url = $data['site_url'];\n        }\n\n        $entry->prepare_icon();\n        $success = $entry->save();\n\n        if ($success === false) {\n            return new \\WP_Error(\n                'podlove_rest_unfurl_save_failed',\n                'error when saving unfurled entry',\n                [\n                    'status' => 404,\n                    'locations' => $data['locations'],\n                ]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function update_item($request)\n    {\n        $params = $request->get_params();\n        $entry = Entry::find_by_id($params['id']);\n\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        if (isset($params['original_url'])) {\n            $entry->original_url = $params['original_url'];\n        }\n\n        if (isset($params['title'])) {\n            $entry->title = $params['title'];\n        }\n\n        if (isset($params['url'])) {\n            $entry->url = $params['url'];\n        }\n\n        if (isset($params['description'])) {\n            $entry->description = $params['description'];\n        }\n\n        if (isset($params['position'])) {\n            $entry->position = $params['position'];\n        }\n\n        if (isset($params['hidden'])) {\n            $entry->hidden = (int) $params['hidden'];\n        }\n\n        $entry->save();\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n\n    private function create_link_item($request, $episode)\n    {\n        $original_url = esc_sql($request['original_url']);\n        $episode_id = (int) $episode->id;\n\n        if (Entry::find_one_by_where(\"episode_id = {$episode_id} AND original_url = '{$original_url}'\")) {\n            return new \\WP_Error(\n                'podlove_rest_duplicate_entry',\n                'a shownotes entry for this URL exists already',\n                ['status' => 400]\n            );\n        }\n\n        $entry = new Entry();\n\n        if (isset($request['data']) && is_array($request['data'])) {\n            // additional data from Slacknotes Import\n            // lower precedence than the other data\n\n            if (isset($request['data']['title'])) {\n                $entry->title = $request['data']['title'];\n            }\n\n            if (isset($request['data']['source'])) {\n                $entry->site_name = $request['data']['source'];\n            }\n\n            if (isset($request['data']['unix_date'])) {\n                $entry->created_at = intval($request['data']['unix_date']) / 1000;\n            }\n\n            if (isset($request['data']['orderNumber'])) {\n                $entry->position = intval($request['data']['orderNumber']) / 1000;\n            }\n        }\n\n        foreach (Entry::property_names() as $property) {\n            if (isset($request[$property]) && $request[$property]) {\n                $entry->{$property} = $request[$property];\n            }\n        }\n\n        // fixme: there is probably a race condition here when adding multiple episodes at once\n        if (!$entry->position) {\n            $entry->position = Entry::get_new_position_for_episode($episode->id);\n        }\n        $entry->episode_id = $episode->id;\n\n        if (!$entry->type) {\n            $entry->type = 'link';\n        }\n\n        if (!$entry->save()) {\n            return new \\WP_Error(\n                'podlove_rest_create_failed',\n                'error when creating entry',\n                ['status' => 400]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        $response = rest_ensure_response($entry->to_array());\n        $response->set_status(201);\n\n        $url = sprintf('%s/%s/%d', self::api_namespace, self::api_base, $entry->id);\n        $response->header('Location', rest_url($url));\n\n        return $response;\n    }\n\n    private function create_topic_item($request, $episode)\n    {\n        if (!$request['title']) {\n            return new \\WP_Error(\n                'podlove_rest_missing_title',\n                'title is required for type \"topic\"',\n                ['status' => 400]\n            );\n        }\n\n        $entry = new Entry();\n\n        foreach (Entry::property_names() as $property) {\n            if (isset($request[$property]) && $request[$property]) {\n                $entry->{$property} = $request[$property];\n            }\n        }\n        // fixme: there is probably a race condition here when adding multiple episodes at once\n        $entry->position = Entry::get_new_position_for_episode($episode->id);\n        $entry->episode_id = $episode->id;\n\n        if (!$entry->type) {\n            $entry->type = 'topic';\n        }\n\n        if (!$entry->save()) {\n            return new \\WP_Error(\n                'podlove_rest_create_failed',\n                'error when creating entry',\n                ['status' => 400]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        $response = rest_ensure_response($entry->to_array());\n        $response->set_status(201);\n\n        $url = sprintf('%s/%s/%d', self::api_namespace, self::api_base, $entry->id);\n        $response->header('Location', rest_url($url));\n\n        return $response;\n    }\n}\n\nclass REST_API_V2\n{\n    public const api_namespace = 'podlove/v2';\n    public const api_base = 'shownotes';\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'permission_check'],\n                'args' => [\n                    'episode_id' => [\n                        'description' => 'Limit result set by episode.',\n                        'type' => 'integer',\n                    ],\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the object.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)/unfurl', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the object.'),\n                    'type' => 'integer',\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'unfurl_item'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/osf', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'import_osf'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n        register_rest_route(self::api_namespace, self::api_base.'/html', [\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'import_html'],\n                'permission_callback' => [$this, 'permission_check'],\n            ],\n        ]);\n    }\n\n    public function import_html($request)\n    {\n        $post_id = $request['post_id'];\n\n        if (!$episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return new \\WP_Error(\n                'podlove_rest_html_no_episode',\n                'episode cannot be found',\n                ['status' => 400]\n            );\n        }\n\n        $html = $request['html'] ?? get_the_content(null, false, $post_id);\n\n        $dom = new \\DOMDocument('1.0');\n        $dom->preserveWhiteSpace = false;\n\n        // load html and ensure utf-8\n        // @see php DOMDocument::loadHTML doc comments\n        $valid = $dom->loadHTML('<?xml encoding=\"UTF-8\">'.$html);\n\n        foreach ($dom->childNodes as $item) {\n            if ($item->nodeType == XML_PI_NODE) {\n                $dom->removeChild($item);\n            }\n        }\n\n        $dom->encoding = 'UTF-8';\n\n        if (!$valid) {\n            return new \\WP_Error(\n                'podlove_rest_html_unreadable',\n                'html could not be parsed',\n                ['status' => 400]\n            );\n        }\n\n        $xpath = new \\DOMXPath($dom);\n\n        foreach ($xpath->query('//a | //h1 | //h2 | //h3 | //h4 | //h5 | //h6') as $element) {\n            if ($element->tagName == 'a') {\n                $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'original_url' => $element->getAttribute('href'),\n                    'data' => [\n                        'title' => $element->textContent,\n                    ],\n                    'type' => 'link',\n                ]);\n                rest_do_request($request);\n            } else {\n                $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'data' => [\n                        'title' => $element->textContent,\n                    ],\n                    'title' => $element->textContent,\n                    'type' => 'topic',\n                ]);\n                rest_do_request($request);\n            }\n        }\n\n        return rest_ensure_response(['message' => 'ok']);\n    }\n\n    public function import_osf($request)\n    {\n        $post_id = $request['post_id'];\n\n        if (!function_exists('osf_parser')) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_function',\n                'function \"osf_parser\" is not available',\n                ['status' => 400]\n            );\n        }\n\n        $shownotes = get_post_meta($post_id, '_shownotes', true);\n\n        $tags = explode(' ', 'chapter section spoiler topic embed video audio image shopping glossary source app title quote link podcast news');\n        $data = [\n            'amazon' => '',\n            'thomann' => '',\n            'tradedoubler' => '',\n            'fullmode' => 'true', // sic\n            'tagsmode' => 1,\n            'tags' => $tags,\n        ];\n        $parsed = osf_parser($shownotes, $data);\n\n        $links = [];\n\n        foreach ($parsed['export'] as $group) {\n            if ($group['chapter']) {\n                $links[] = [\n                    'type' => 'topic',\n                    'title' => $group['orig']\n                ];\n            }\n            foreach ($group['subitems'] as $link) {\n                $link['type'] = 'link';\n                $links[] = $link;\n            }\n        }\n\n        if (!is_array($links)) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_links',\n                'there are no osf shownotes or links in them',\n                ['status' => 400]\n            );\n        }\n\n        $links = array_map(function ($link) {\n            if ($link['type'] == 'link') {\n                if (!$link['orig'] || !$link['urls'] || !count($link['urls'])) {\n                    return null;\n                }\n\n                return [\n                    'type' => 'link',\n                    'title' => $link['orig'],\n                    'url' => $link['urls'][0],\n                ];\n            }\n\n            return $link;\n        }, $links);\n        $links = array_filter($links);\n\n        if (!$episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return new \\WP_Error(\n                'podlove_rest_osf_no_episode',\n                'episode cannot be found',\n                ['status' => 400]\n            );\n        }\n\n        foreach ($links as $link) {\n            $request = new \\WP_REST_Request('POST', '/podlove/v1/shownotes');\n            if ($link['type'] == 'link') {\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'original_url' => $link['url'],\n                    'data' => [\n                        'title' => $link['title'],\n                    ],\n                    'type' => 'link',\n                ]);\n            } else {\n                $request->set_query_params([\n                    'episode_id' => $episode->id,\n                    'data' => [\n                        'title' => $link['title'],\n                    ],\n                    'title' => $link['title'],\n                    'type' => 'topic',\n                ]);\n            }\n            rest_do_request($request);\n        }\n\n        return rest_ensure_response(['message' => 'ok']);\n    }\n\n    public function get_items($request)\n    {\n        $episode_id = $request['episode_id'];\n\n        if (!$episode_id) {\n            return new \\WP_Error(\n                'podlove_rest_missing_episode_id',\n                'episode_id is required',\n                ['status' => 400]\n            );\n        }\n\n        $entries = Entry::find_all_by_property('episode_id', $episode_id);\n        $entries = array_map(function ($entry) {\n            $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n            return $entry->to_array();\n        }, $entries);\n\n        return rest_ensure_response($entries);\n    }\n\n    public function create_item($request)\n    {\n        if (!$request['episode_id']) {\n            return new \\WP_Error(\n                'podlove_rest_missing_episode_id',\n                'episode_id is required',\n                ['status' => 400]\n            );\n        }\n\n        $episode = Episode::find_by_id($request['episode_id']);\n\n        if (!$episode) {\n            return new \\WP_Error(\n                'podlove_rest_episode_not_found',\n                'episode does not exist',\n                ['status' => 400]\n            );\n        }\n\n        if ($request['type'] == 'link') {\n            return $this->create_link_item($request, $episode);\n        }\n        if ($request['type'] == 'topic') {\n            return $this->create_topic_item($request, $episode);\n        }\n    }\n\n    public function get_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function delete_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n        $response = rest_ensure_response(['deleted' => true]);\n\n        if (!$entry) {\n            return new \\WP_Error('podlove_rest_already_deleted', 'The entry has already been deleted.', ['status' => 410]);\n        }\n\n        $success = $entry->delete();\n\n        if (!$success) {\n            return new \\WP_Error('podlove_rest_cannot_delete', 'The entry cannot be deleted.', ['status' => 500]);\n        }\n\n        return $response;\n    }\n\n    public function unfurl_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        $url = $entry->original_url;\n\n        $unfurl_endpoint = 'https://plus.podlove.org/api/unfurl';\n        $curl = new Curl();\n        $curl->request(add_query_arg('url', urlencode($url), $unfurl_endpoint), [\n            'headers' => ['Content-type' => 'application/json'],\n            'timeout' => 20,\n        ]);\n\n        $response = $curl->get_response();\n\n        if (!$curl->isSuccessful()) {\n            $entry->state = 'failed';\n            $entry->save();\n\n            $body = json_decode($response['body'], true);\n            $reason = $body['error']['reason'] ?? 'unknown reason';\n\n            return new \\WP_Error(\n                'podlove_rest_unfurl_failed',\n                'error when unfurling entry ('.print_r($reason, true).')',\n                ['status' => 404]\n            );\n        }\n\n        $data = json_decode(\\Podlove\\maybe_encode_emoji($response['body']), true);\n\n        // remove \"data:...\" images because they are too huge to store in database\n        $url_size_threshold = 1000;\n\n        if (strlen($data['icon']['url']) > $url_size_threshold) {\n            unset($data['icon']);\n        }\n\n        foreach ($data['providers']['misc']['icons'] as $index => $icon) {\n            if (strlen($icon['url']) > $url_size_threshold) {\n                unset($data['providers']['misc']['icons'][$index]);\n            }\n        }\n\n        $entry->unfurl_data = $data;\n        $entry->state = 'fetched';\n        $entry->url = $data['url'];\n        $entry->icon = $data['icon']['url'];\n        $entry->image = $data['image'];\n\n        // todo: should probably do this in an async job\n        $attachment_id = 0;\n\n        if ($data['image']) {\n            $attachment_id = \\Podlove\\download_external_image_to_media($data['image'], explode('?', basename($data['image']))[0]);\n        }\n\n        if (!$attachment_id && $data['screenshot_url']) {\n            if (\\Podlove\\Modules\\Base::is_active('plus')) {\n                $plus = \\Podlove\\Modules\\Plus\\Plus::instance();\n                $curl_args = [\n                    'headers' => [\n                        'Authorization' => 'Bearer '.$plus->get_module_option('plus_api_token')\n                    ]\n                ];\n                $attachment_id = \\Podlove\\download_external_image_to_media($data['screenshot_url'], 'screenshot.jpg', $curl_args);\n            }\n        }\n\n        if ($attachment_id && !\\is_wp_error($attachment_id)) {\n            $attachment_url = \\wp_get_attachment_url($attachment_id);\n            $entry->image = $attachment_url;\n        }\n\n        if (!$entry->title) {\n            $entry->title = $data['title'];\n        }\n\n        if (!$entry->description) {\n            $entry->description = $data['description'];\n        }\n\n        if (!$entry->site_name) {\n            $entry->site_name = $data['site_name'];\n        }\n\n        if (!$entry->site_url) {\n            $entry->site_url = $data['site_url'];\n        }\n\n        $entry->prepare_icon();\n        $success = $entry->save();\n\n        if ($success === false) {\n            return new \\WP_Error(\n                'podlove_rest_unfurl_save_failed',\n                'error when saving unfurled entry',\n                [\n                    'status' => 404,\n                    'locations' => $data['locations'],\n                ]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function update_item($request)\n    {\n        $entry = Entry::find_by_id($request['id']);\n        if (is_wp_error($entry)) {\n            return $entry;\n        }\n\n        if (isset($request['original_url'])) {\n            $entry->original_url = $request['original_url'];\n        }\n\n        if (isset($request['title'])) {\n            $entry->title = $request['title'];\n        }\n\n        if (isset($request['url'])) {\n            $entry->url = $request['url'];\n        }\n\n        if (isset($request['description'])) {\n            $entry->description = $request['description'];\n        }\n\n        if (isset($request['position'])) {\n            $entry->position = $request['position'];\n        }\n\n        if (isset($request['hidden'])) {\n            $entry->hidden = (int) $request['hidden'];\n        }\n\n        $entry->save();\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        return rest_ensure_response($entry->to_array());\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n\n    private function create_link_item($request, $episode)\n    {\n        $original_url = esc_sql($request['original_url']);\n        $episode_id = (int) $episode->id;\n\n        if (Entry::find_one_by_where(\"episode_id = {$episode_id} AND original_url = '{$original_url}'\")) {\n            return new \\WP_Error(\n                'podlove_rest_duplicate_entry',\n                'a shownotes entry for this URL exists already',\n                ['status' => 400]\n            );\n        }\n\n        $entry = new Entry();\n\n        if (isset($request['data']) && is_array($request['data'])) {\n            // additional data from Slacknotes Import\n            // lower precedence than the other data\n\n            if (isset($request['data']['title'])) {\n                $entry->title = $request['data']['title'];\n            }\n\n            if (isset($request['data']['source'])) {\n                $entry->site_name = $request['data']['source'];\n            }\n\n            if (isset($request['data']['unix_date'])) {\n                $entry->created_at = intval($request['data']['unix_date']) / 1000;\n            }\n\n            if (isset($request['data']['orderNumber'])) {\n                $entry->position = intval($request['data']['orderNumber']) / 1000;\n            }\n        }\n\n        foreach (Entry::property_names() as $property) {\n            if (isset($request[$property]) && $request[$property]) {\n                $entry->{$property} = $request[$property];\n            }\n        }\n\n        // fixme: there is probably a race condition here when adding multiple episodes at once\n        if (!$entry->position) {\n            $entry->position = Entry::get_new_position_for_episode($episode->id);\n        }\n        $entry->episode_id = $episode->id;\n\n        if (!$entry->type) {\n            $entry->type = 'link';\n        }\n\n        if (!$entry->save()) {\n            return new \\WP_Error(\n                'podlove_rest_create_failed',\n                'error when creating entry',\n                ['status' => 400]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        $response = rest_ensure_response($entry->to_array());\n        $response->set_status(201);\n\n        $url = sprintf('%s/%s/%d', self::api_namespace, self::api_base, $entry->id);\n        $response->header('Location', rest_url($url));\n\n        return $response;\n    }\n\n    private function create_topic_item($request, $episode)\n    {\n        if (!$request['title']) {\n            return new \\WP_Error(\n                'podlove_rest_missing_title',\n                'title is required for type \"topic\"',\n                ['status' => 400]\n            );\n        }\n\n        $entry = new Entry();\n\n        foreach (Entry::property_names() as $property) {\n            if (isset($request[$property]) && $request[$property]) {\n                $entry->{$property} = $request[$property];\n            }\n        }\n        // fixme: there is probably a race condition here when adding multiple episodes at once\n        $entry->position = Entry::get_new_position_for_episode($episode->id);\n        $entry->episode_id = $episode->id;\n\n        if (!$entry->type) {\n            $entry->type = 'topic';\n        }\n\n        if (!$entry->save()) {\n            return new \\WP_Error(\n                'podlove_rest_create_failed',\n                'error when creating entry',\n                ['status' => 400]\n            );\n        }\n\n        $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n        $response = rest_ensure_response($entry->to_array());\n        $response->set_status(201);\n\n        $url = sprintf('%s/%s/%d', self::api_namespace, self::api_base, $entry->id);\n        $response->header('Location', rest_url($url));\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "lib/modules/shownotes/shownotes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shownotes;\n\nuse Podlove\\Modules\\Affiliate\\Affiliate;\nuse Podlove\\Modules\\Shownotes\\Model\\Entry;\n\nclass Shownotes extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Shownotes';\n    protected $module_description = 'Manage link based episode show notes to display on your website and podcatchers. Helps you provide rich metadata for URLs. Full support for Publisher Templates.';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        add_action('add_meta_boxes', [$this, 'add_meta_box']);\n        add_action('podlove_module_was_activated_shownotes', [$this, 'was_activated']);\n        add_action('rest_api_init', [$this, 'api_init']);\n        add_filter('podlove_shownotes_entry', [__CLASS__, 'apply_affiliate_to_shownotes_entry']);\n        add_filter('podlove_shownotes_entry', [__CLASS__, 'encode_html']);\n\n        add_filter('podlove_twig_file_loader', function ($file_loader) {\n            $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'shownotes', 'twig']), 'shownotes');\n\n            return $file_loader;\n        });\n\n        add_shortcode('podlove-episode-shownotes', [$this, 'shownotes_shortcode']);\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'shownotes',\n            ['\\Podlove\\Modules\\Shownotes\\TemplateExtensions', 'accessorEpisodeShownotes'],\n            5\n        );\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'hasShownotes',\n            ['\\Podlove\\Modules\\Shownotes\\TemplateExtensions', 'accessorEpisodeHasShownotes'],\n            4\n        );\n    }\n\n    public function was_activated()\n    {\n        Entry::build();\n    }\n\n    public function uninstall()\n    {\n        Entry::destroy();\n    }\n\n    public function add_meta_box()\n    {\n        $post_id = get_the_ID();\n        $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id);\n\n        add_meta_box(\n            // $id\n            'podlove_podcast_shownotes',\n            // $title\n            __('Podlove Shownotes', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () use ($episode) {\n                $id = esc_attr($episode->id);\n                echo <<<HTML\n                    <div id=\"podlove-shownotes-app\">\n                        <shownotes episodeid=\"{$id}\"></shownotes>\n                    </div>\nHTML;\n            },\n            // $page\n            'podcast',\n            // $context\n            'normal',\n            // $priority\n            'low'\n        );\n    }\n\n    public function api_init()\n    {\n        $api_v1 = new REST_API();\n        $api_v1->register_routes();\n        $api_v2 = new REST_API_V2();\n        $api_v2->register_routes();\n    }\n\n    public static function apply_affiliate_to_shownotes_entry(Entry $entry)\n    {\n        $url = $entry->url;\n\n        if (!$url) {\n            return $entry;\n        }\n\n        if (stripos($url, 'amazon.de') !== false) {\n            $entry->affiliate_url = Affiliate::apply_amazon_de_affiliate($url);\n        } elseif (stripos($url, 'thomann.de') !== false) {\n            $entry->affiliate_url = Affiliate::apply_thomann_de_affiliate($url);\n        }\n\n        return $entry;\n    }\n\n    public static function encode_html(Entry $entry)\n    {\n        $entry->title = html_entity_decode($entry->title ?? '');\n        $entry->description = html_entity_decode($entry->description ?? '');\n\n        return $entry;\n    }\n\n    public function shownotes_shortcode($attributes)\n    {\n        if (!is_array($attributes)) {\n            $attributes = [];\n        }\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@shownotes/shownotes.twig', $attributes);\n    }\n}\n"
  },
  {
    "path": "lib/modules/shownotes/template/entry.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shownotes\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Shownotes entry Template Wrapper.\n *\n * @templatetag entry\n */\nclass Entry extends Wrapper\n{\n    private $entry;\n\n    public function __construct($entry)\n    {\n        $this->entry = $entry;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->entry->title;\n    }\n\n    /**\n     * Description.\n     *\n     * @accessor\n     */\n    public function description()\n    {\n        return $this->entry->description;\n    }\n\n    /**\n     * Canonical URL.\n     *\n     * Defaults to `original_url` if no canonical URL is available.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->entry->affiliate_url ?? $this->entry->url ?? $this->entry->original_url;\n    }\n\n    /**\n     * Does this entry have an affiliate URL?\n     *\n     * @accessor\n     */\n    public function hasAffiliateUrl()\n    {\n        return (bool) $this->entry->affiliate_url;\n    }\n\n    /**\n     * User provided URL.\n     *\n     * @accessor\n     */\n    public function originalUrl()\n    {\n        return $this->entry->original_url;\n    }\n\n    /**\n     * Website name.\n     *\n     * @accessor\n     */\n    public function siteName()\n    {\n        return $this->entry->site_name;\n    }\n\n    /**\n     * Website URL.\n     *\n     * Example: The site url of https://example.com/page?param=42 is https://example.com.\n     *\n     * @accessor\n     */\n    public function siteUrl()\n    {\n        return $this->entry->site_url;\n    }\n\n    /**\n     * Icon URL.\n     *\n     * @accessor\n     */\n    public function icon()\n    {\n        return $this->entry->icon;\n    }\n\n    /**\n     * Image URL.\n     *\n     * Open Graph image.\n     *\n     * @see image\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        $data = \\unserialize($this->entry->unfurl_data);\n\n        if (!$data) {\n            return false;\n        }\n\n        $image_url = $this->entry->image ?? $data['providers']['open_graph']['image'] ?? $data['providers']['twitter']['image:src'] ?? false;\n\n        if (is_array($image_url)) {\n            $image_url = $image_url[0];\n        }\n\n        if ($image_url) {\n            return new \\Podlove\\Template\\Image((new \\Podlove\\Model\\Image($image_url, $this->entry->title))->setWidth(1024));\n        }\n\n        return null;\n    }\n\n    /**\n     * SVG Icon for entry type.\n     *\n     * @accessor\n     *\n     * @param mixed $size\n     */\n    public function typeIcon($size = 0)\n    {\n        if ($size && $size > 0) {\n            $size_style = \"width: {$size}px; height: {$size}px;\";\n        } else {\n            $size_style = '';\n        }\n\n        switch ($this->entry->type) {\n            case 'link':\n                return <<<SVG\n<svg xmlns=\"http://www.w3.org/2000/svg\" style=\"fill: none !important; {$size_style}\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path></svg>\nSVG;\n\n                break;\n            case 'topic':\n                return <<<SVG\n  <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"fill: none !important; {$size_style}\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"4 7 4 4 20 4 20 7\"></polyline><line x1=\"9\" y1=\"20\" x2=\"15\" y2=\"20\"></line><line x1=\"12\" y1=\"4\" x2=\"12\" y2=\"20\"></line></svg>\nSVG;\n\n                break;\n        }\n\n        return null;\n    }\n\n    /**\n     * Type.\n     *\n     * Either \"text\" or \"link\".\n     *\n     * @accessor\n     */\n    public function type()\n    {\n        return $this->entry->type;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->entry];\n    }\n}\n"
  },
  {
    "path": "lib/modules/shownotes/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shownotes;\n\nclass TemplateExtensions\n{\n    /**\n     * Episode Shownotes (Beta Release only).\n     *\n     * **Examples**\n     *\n     * Display all shownotes in a list.\n     *\n     * ```\n     * <ul>\n     * {% for entry in episode.shownotes %}\n     *   <li class=\"psn-entry\">\n     *     {% if entry.type == \"link\" %}\n     *       {% if entry.icon %}\n     *         <img class=\"psn-icon\" src=\"{{ entry.icon }}\" />\n     *       {% endif %}\n     *       <a class=\"psn-link\" href=\"{{ entry.url }}\">{{ entry.title }}</a>\n     *     {% elseif entry.type == \"topic\" %}\n     *       {{ entry.title }}\n     *     {% endif %}\n     *   </li>\n     * {% endfor %}\n     * </ul>\n     * ```\n     *\n     * Group shownotes by topic.\n     *\n     * ```\n     * {% for topic in episode.shownotes({groupby: \"topic\"}) %}\n     *   <h3>{{ topic.title }}</h3>\n     *\n     *   <ul>\n     *     {% for entry in topic.entries %}\n     *       <li class=\"psn-entry\">\n     *         {% if entry.type == \"link\" %}\n     *           {% if entry.icon %}\n     *             <img class=\"psn-icon\" src=\"{{ entry.icon }}\"/>\n     *           {% endif %}\n     *           <a class=\"psn-link\" href=\"{{ entry.url }}\">{{ entry.title }}</a>\n     *         {% endif %}\n     *       </li>\n     *     {% endfor %}\n     *   </ul>\n     * {% endfor %}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.shownotes\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $post\n     * @param mixed $args\n     */\n    public static function accessorEpisodeShownotes($return, $method_name, \\Podlove\\Model\\Episode $episode, $post, $args = [])\n    {\n        return $episode->with_blog_scope(function () use ($episode, $args) {\n            $defaults = [\n                'groupby' => false,\n            ];\n            $args = wp_parse_args($args, $defaults);\n\n            $entries = Model\\Entry::find_all_by_property('episode_id', $episode->id);\n\n            if (!is_array($entries)) {\n                return [];\n            }\n\n            // discard entries with failed state, unless a url was entered manually\n            // ensure it's not hidden\n            $entries = array_filter($entries, function ($e) {\n                $unfurl_failed = $e->state == 'failed';\n                $has_manual_url = strlen($e->url) > 0;\n                $is_hidden = $e->hidden;\n\n                // return !$e->hidden && (!$unfurl_failed || $has_manual_url);\n                return !$is_hidden;\n            });\n\n            usort($entries, function ($a, $b) {\n                if ($a->position == $b->position) {\n                    return 0;\n                }\n\n                return ($a->position < $b->position) ? -1 : 1;\n            });\n\n            if ($args['groupby'] == 'topic') {\n                $tmp = array_reduce($entries, function ($agg, $item) {\n                    $item = apply_filters('podlove_shownotes_entry', $item);\n\n                    if ($item->type == 'topic') {\n                        $agg['result'][] = [\n                            'title' => $item->title,\n                            'entries' => [],\n                        ];\n\n                        $agg['topic_index'] = count($agg['result']) - 1;\n                    } else {\n                        if ($agg['topic_index'] == null) {\n                            $agg['result'][] = [\n                                'title' => '',\n                                'entries' => [],\n                            ];\n                            $agg['topic_index'] = count($agg['result']) - 1;\n                        }\n\n                        $agg['result'][$agg['topic_index']]['entries'][] = new Template\\Entry($item);\n                    }\n\n                    return $agg;\n                }, ['result' => [], 'topic_index' => null]);\n\n                return $tmp['result'];\n            }\n\n            return array_map(function ($entry) {\n                $entry = apply_filters('podlove_shownotes_entry', $entry);\n\n                return new Template\\Entry($entry);\n            }, $entries);\n        });\n    }\n\n    /**\n     * Check if an episode has shownotes.\n     *\n     * **Examples**\n     *\n     * ```\n     * {% if episode.hasShownotes %}\n     *   Here are some shownotes\n     * {% else %}\n     *   ¯\\_(ツ)_/¯\n     * {% endif %}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.hasShownotes\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     */\n    public static function accessorEpisodeHasShownotes($return, $method_name, \\Podlove\\Model\\Episode $episode)\n    {\n        return $episode->with_blog_scope(function () use ($episode) {\n            return Model\\Entry::has_shownotes($episode->id);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/shownotes/twig/plain-html-list-grouped.twig",
    "content": "{% for topic in episode.shownotes({groupby: \"topic\"}) %}\n\t{% if topic.title %}\n\t\t<br>\n\t\t<h5>{{ topic.title }}</h5>\n\t{% endif %}\n\n\t<ul>\n\t\t{% for entry in topic.entries %}\n\t\t\t<li>\n\t\t\t\t{% if entry.type == \"link\" %}\n\t\t\t\t\t<a href=\"{{ entry.url }}\" target=\"_blank\">{{ entry.title }}</a>\n\t\t\t\t{% endif %}\n\t\t\t</li>\n\t\t{% endfor %}\n\t</ul>\n{% endfor %}\n"
  },
  {
    "path": "lib/modules/shownotes/twig/plain-html-list.twig",
    "content": "<ul>\n\t{% for entry in episode.shownotes %}\n\t\t<li>\n\t\t\t{% if entry.type == \"link\" %}\n\t\t\t\t<a href=\"{{ entry.url }}\">{{ entry.title ? entry.title : entry.url }}</a>\n\t\t\t{% elseif entry.type == \"topic\" %}\n\t\t\t\t<strong>{{ entry.title }}</strong>\n\t\t\t{% endif %}\n\t\t</li>\n\t{% endfor %}\n</ul>\n"
  },
  {
    "path": "lib/modules/shownotes/twig/shownotes.twig",
    "content": "{% if is_feed() %}\n\n\t{% include '@shownotes/plain-html-list.twig' %}\n\n{% else %}\n\n\t<style>\n\t\t.psn-entry {\n\t\t\tdisplay: flex;\n\t\t\talign-items: start;\n\t\t\tmargin-bottom: 8px;\n\t\t}\n\t\t.psn-icon {\n\t\t\twidth: 16px;\n\t\t\theight: 16px;\n\t\t\tmargin-right: 5px;\n\t\t\tmargin-top: 5px;\n\t\t\tflex-shrink: 0;\n\t\t}\n\t\t.psn-icon-default {\n\t\t\tbackground: rgb(209, 213, 219);\n\t\t}\n\t\ta.psn-link {\n\t\t\ttext-decoration: none !important;\n\t\t\tfont-size: 16px;\n\t\t\tline-height: 24px;\n\t\t}\n\t</style>\n\n\t<ul>\n\t\t{% for entry in episode.shownotes %}\n\t\t\t<li class=\"psn-entry\">\n\t\t\t\t{% if entry.type == \"link\" %}\n\t\t\t\t\t{% if entry.icon %}\n\t\t\t\t\t\t<img class=\"psn-icon\" src=\"{{ entry.icon }}\"/>\n\t\t\t\t\t{% else %}\n\t\t\t\t\t\t<div class=\"psn-icon psn-icon-default\">&nbsp;</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t<a class=\"psn-link\" href=\"{{ entry.url }}\">{{ entry.title ? entry.title : entry.url }}</a>\n\t\t\t\t{% elseif entry.type == \"topic\" %}\n\t\t\t\t\t<strong>{{ entry.title }}</strong>\n\t\t\t\t{% endif %}\n\t\t\t</li>\n\t\t{% endfor %}\n\t</ul>\n\n{% endif %}\n"
  },
  {
    "path": "lib/modules/shows/js/admin.js",
    "content": "jQuery(document).ready(function($) {\n\n\t/*\n\t * Creates a Show feed preview based on a generic Podcast feed\n\t */\n\tvar $update_feed_preview = function () {\n\t\t$url_preview = $(\"#feed_subscribe_url_preview\");\n\t\t$show_slug = $(\"#podlove_show_slug\").val();\n\t\t\n\t\tif ( $show_slug !== '' ) {\n\t\t\t$slug_preview_url = $url_preview.data('show-preview-string') + ' ' +\n\t\t\t\t$url_preview.data('show-feed-base-url') + '/show/' +\n\t\t\t\t$show_slug + '/feed/' +\n\t\t\t\t$url_preview.data('show-feed-slug');\n\t\t} else {\n\t\t\t$slug_preview_url = '';\n\t\t}\n\t\t\n\t\t$url_preview.text($slug_preview_url);\n\t}\n\n\t$update_feed_preview();\n\n\t$(\"#podlove_show_slug\").on( 'keyup', function () {\n\t\t$update_feed_preview();\n\t} );\n\n});\n\n\n\n\n"
  },
  {
    "path": "lib/modules/shows/model/show.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows\\Model;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Image;\nuse Ramsey\\Uuid\\Uuid as UUID;\n\nclass Show\n{\n    public $id;\n    public $title;\n    public $subtitle;\n    public $slug;\n    public $summary;\n    public $image;\n    public $language;\n    public $category;\n    public $auphonic_preset;\n    public $guid;\n\n    /**\n     * A show object consists of the following properties:\n     *     - Title/Name\n     *     - Subtitle*\n     *     - Slug\n     *     - Description\n     *     - Image*\n     *     - Language*\n     *     - Category*.\n     *\n     * Properties marked with * are meta\n     */\n    public function __construct()\n    {\n        $this->id = false;\n        $this->title = '';\n        $this->subtitle = '';\n        $this->slug = '';\n        $this->summary = '';\n        $this->image = '';\n        $this->language = '';\n        $this->category = '';\n        $this->auphonic_preset = '';\n        $this->guid = '';\n    }\n\n    /**\n     * Searches all Show terms and returns all values matching $property == $value.\n     *\n     * @param string $property\n     * @param string $value\n     *\n     * @return array\n     */\n    public static function find_all_terms_by_property($property = false, $value = false)\n    {\n        $existing_properties = ['title', 'description', 'slug', 'id'];\n        $existing_meta_properties = ['image', 'language', 'subtitle', 'category'];\n        $search_parameters = [\n            'taxonomy' => 'shows',\n            'hide_empty' => false,\n        ];\n\n        if (in_array($property, $existing_meta_properties)) {\n            $search_parameters['meta_key'] = $property;\n            $search_parameters['meta_value'] = $value;\n        }\n\n        if (in_array($property, $existing_properties)) {\n            switch ($property) {\n                case 'id':\n                    $search_parameters['term_taxonomy_id'] = $value;\n\n                    break;\n                case 'title':\n                    $search_parameters['name'] = $value;\n\n                    break;\n                case 'description':\n                    $search_parameters['description__like'] = $value;\n\n                    break;\n\n                default:\n                    $search_parameters[$property] = $value;\n\n                    break;\n            }\n        }\n\n        return self::format_terms(get_terms($search_parameters));\n    }\n\n    public static function find_one_term_by_property($property = false, $value = false)\n    {\n        $terms = self::find_all_terms_by_property($property, $value);\n\n        if (is_array($terms) && !empty($terms)) {\n            return $terms[0]; // returns first element only\n        }\n    }\n\n    public static function find_by_id($id)\n    {\n        return self::format_term(get_term($id, 'shows'));\n    }\n\n    public static function find_one_by_episode_id($episode_id)\n    {\n        $episode = Episode::find_by_id($episode_id);\n\n        return self::find_one_by_post_id($episode->post_id);\n    }\n\n    public static function find_one_by_post_id($post_id)\n    {\n        $postterms = get_the_terms($post_id, 'shows');\n\n        return isset($postterms[0]) ? self::find_by_id($postterms[0]->term_id) : false;\n    }\n\n    public static function all()\n    {\n        return self::find_all_terms_by_property();\n    }\n\n    /**\n     * Returns terms as a well-defined object including all meta data.\n     *\n     * @param mixed $terms Term(s) to be formated\n     *\n     * @return mixed Returns an array if an array or object based on the type of $terms\n     */\n    public static function format_terms($terms)\n    {\n        if (is_array($terms)) {\n            return array_map([__CLASS__, 'format_term'], $terms);\n        }\n\n        return self::format_term($terms);\n    }\n\n    /**\n     * Convert show term to instance of this show class.\n     *\n     * @param [type] $term [description]\n     *\n     * @return [type] [description]\n     */\n    public static function format_term($term)\n    {\n        $show = new Show();\n        $show->id = $term->term_id;\n        $show->title = $term->name;\n        $show->subtitle = get_term_meta($term->term_id, 'subtitle', true);\n        $show->slug = $term->slug;\n        $show->summary = $term->description;\n        $show->image = get_term_meta($term->term_id, 'image', true);\n        $show->language = get_term_meta($term->term_id, 'language', true);\n        $show->category = get_term_meta($term->term_id, 'category', true);\n        $show->auphonic_preset = get_term_meta($term->term_id, 'auphonic_preset', true);\n        $show->guid = get_term_meta($term->term_id, 'guid', true);\n\n        return $show;\n    }\n\n    public function image()\n    {\n        return new Image($this->image, $this->title);\n    }\n\n    public static function generate_guid($term_id)\n    {\n        if (!get_term_meta($term_id, 'guid', true)) {\n            update_term_meta($term_id, 'guid', UUID::uuid4());\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Shows\\Model\\Show;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v2';\n    public const api_base = 'shows';\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base, [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'permission_check'],\n            ]\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/next_episode_number', [\n            [\n                'args' => [\n                    'show' => [\n                        'description' => 'show slug',\n                        'type' => 'string'\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_next_episode_number'],\n                'permission_callback' => [$this, 'permission_check'],\n            ]\n        ]);\n    }\n\n    public function get_items($request)\n    {\n        $shows = Show::all();\n\n        $shows = array_map(function ($show) {\n            $show = (array) $show;\n            $show['feeds'] = \\Podlove\\Api\\Feeds\\WP_REST_PodloveFeed_Controller::get_feeds('shows', $show['id']);\n\n            return $show;\n        }, $shows);\n\n        return rest_ensure_response($shows);\n    }\n\n    public function get_next_episode_number($request)\n    {\n        $slug = $request->get_param('show');\n        $show = $slug ? Show::find_one_term_by_property('slug', $slug) : null;\n\n        return Episode::get_next_episode_number($show ? $show->slug : null);\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/settings/help/settings.php",
    "content": "<?php\n\nreturn [\n    'podlove_help_shows' => [\n        'title' => __('Shows', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n        .sprintf(\n            __(\n                'Use shows to offer feeds to subtopics of your podcast. If your shows are unrelated, a WordPress Network is better suited than the shows module. Have a look at %sthe documentation%s for a detailed overview.', // @todo: Add a good description on the differences between shows and networks.\n                'podlove-podcasting-plugin-for-wordpress'\n            ),\n            '<a href=\"http://docs.podlove.org/podlove-publisher/guides/podcast-network.html\" target=\"_blank\">',\n            '</a>'\n        ).'</p>',\n    ],\n    'podlove_help_shows_slug' => [\n        'title' => __('Show Slug', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>\n\t\t\t\t'.__('The slug is used to create unique feeds for each show. Please consider the URL preview to see how the slug is used to create show-specific feeds. An overview over all show specific feeds is available on the <a href=\"?page=podlove_shows_settings\">Show landing page</a>.', 'podlove-podcasting-plugin-for-wordpress').'\n\t\t\t</p>',\n    ],\n];\n"
  },
  {
    "path": "lib/modules/shows/settings/settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows\\Settings;\n\nuse Podlove\\Modules\\Shows\\Model\\Show;\n\nclass Settings\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public const MENU_SLUG = 'podlove_shows_settings';\n    private static $nonce = 'update_shows';\n\n    public function __construct($handle)\n    {\n        $pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Shows', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Shows', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation($pagehook);\n\n        add_action('admin_init', [$this, 'process_form']);\n        add_action('load-'.$pagehook, [$this, 'add_screen_options']);\n    }\n\n    public static function show_meta_data_fields()\n    {\n        return ['subtitle', 'language', 'image', 'category', 'auphonic_preset'];\n    }\n\n    public function add_screen_options()\n    {\n        add_screen_option('per_page', [\n            'label' => __('Shows', 'podlove-podcasting-plugin-for-wordpress'),\n            'default' => 10,\n            'option' => 'podlove_shows_per_page',\n        ]);\n\n        $this->table = new ShowListTable();\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['show'])) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        if (!in_array($action, ['save', 'create', 'delete'])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        }\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Shows', 'podlove'); ?><a href=\"#\" data-podlove-help=\"podlove_help_shows\"><sup>?</sup></a> <a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=new\" class=\"add-new-h2\"><?php echo __('Add New', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\n\t\t\t<?php\nif (isset($_GET['action']) && $_GET['action'] == 'confirm_delete') {\n    $show = Show::find_by_id($_REQUEST['show']); ?>\n\t\t\t\t\t<div class=\"updated\">\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t<strong>\n\t\t\t\t\t\t\t\t<?php echo sprintf(__('You selected to delete the show \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'), $show->title); ?>\n\t\t\t\t\t\t\t</strong>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t<?php echo self::get_action_link($show, __('Delete permanently', 'podlove-podcasting-plugin-for-wordpress', $show->id), 'delete', 'button'); ?>\n\t\t\t\t\t\t\t<?php echo self::get_action_link($show, __('Don\\'t change anything', 'podlove-podcasting-plugin-for-wordpress', $show->id), 'keep', 'button-primary'); ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<?php\n}\n\n        $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n        switch ($action) {\n            case 'new':$this->new_template();\n\n                break;\n            case 'edit':$this->edit_template();\n\n                break;\n            case 'index':$this->view_template();\n\n                break;\n\n            default:$this->view_template();\n\n                break;\n        } ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $episode_asset_id\n     * @param mixed      $params\n     */\n    private function redirect($action, $episode_asset_id = null, $params = [])\n    {\n        $page = 'admin.php?page='.self::MENU_SLUG;\n        $show = ($episode_asset_id) ? '&show='.$episode_asset_id : '';\n        $action = '&action='.$action;\n\n        array_walk($params, function (&$value, $key) {\n            $value = \"&{$key}={$value}\";\n        });\n\n        wp_redirect(admin_url($page.$show.$action.implode('', $params)));\n        exit;\n    }\n\n    /**\n     * Process form: save/update a show.\n     */\n    private function save()\n    {\n        if (!isset($_REQUEST['show'])) {\n            return;\n        }\n\n        $updated_term = wp_update_term(\n            $_REQUEST['show'],\n            'shows',\n            [\n                'name' => $_POST['podlove_show']['title'],\n                'description' => $_POST['podlove_show']['summary'],\n                'slug' => $_POST['podlove_show']['slug'],\n            ]\n        );\n\n        // Add meta entries\n        if (is_wp_error($updated_term)) {\n            return;\n        }\n\n        foreach (self::show_meta_data_fields() as $meta_data) {\n            update_term_meta($_REQUEST['show'], $meta_data, $_POST['podlove_show'][$meta_data]);\n        }\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $_REQUEST['show']);\n        } else {\n            $this->redirect('index', $_REQUEST['show']);\n        }\n    }\n\n    /**\n     * Process form: create a show.\n     */\n    private function create()\n    {\n        if (!$_POST['podlove_show']) {\n            return;\n        }\n\n        // Create new term\n        $new_term = wp_insert_term(\n            $_POST['podlove_show']['title'],\n            'shows',\n            [\n                'description' => $_POST['podlove_show']['summary'],\n                'slug' => $_POST['podlove_show']['slug'],\n            ]\n        );\n\n        // Add meta entries\n        if (is_wp_error($new_term)) {\n            return;\n        }\n\n        foreach (self::show_meta_data_fields() as $meta_data) {\n            add_term_meta($new_term['term_id'], $meta_data, $_POST['podlove_show'][$meta_data]);\n        }\n\n        Show::generate_guid($new_term['term_id']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $new_term['term_id']);\n        } else {\n            $this->redirect('index');\n        }\n    }\n\n    /**\n     * Process form: delete a format.\n     */\n    private function delete()\n    {\n        if (!isset($_REQUEST['show'])) {\n            return;\n        }\n\n        foreach (self::show_meta_data_fields() as $meta_data) {\n            delete_term_meta($_REQUEST['show'], $meta_data);\n        }\n\n        wp_delete_term($_REQUEST['show'], 'shows');\n\n        $this->redirect('index');\n    }\n\n    private function new_template()\n    {\n        $show = new Show(); ?>\n\t\t<h3><?php echo __('Add New Show', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<?php\n$this->form_template($show, 'create', __('Add New Show', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    private function view_template()\n    {\n        ?>\n\t\t<style type=\"text/css\">\n\t\t.wp-list-table.shows .column-image    { width: 150px; }\n\t\t.wp-list-table.shows .column-title    { width: 250px; }\n\t\t.wp-list-table.shows .column-episodes { width: 90px; }\n\t\t</style>\n\t\t<?php\n        $this->table->prepare_items();\n        $this->table->display();\n    }\n\n    private function form_template($show, $action, $button_text = null)\n    {\n        $form_args = [\n            'context' => 'podlove_show',\n            'hidden' => [\n                'show' => $show->id,\n                'action' => $action,\n            ],\n            'submit_button' => false, // for custom control in form_end\n            'form_end' => function () {\n                echo '<p>';\n                submit_button(__('Save Changes'), 'primary', 'submit', false);\n                echo ' ';\n                submit_button(__('Save Changes and Continue Editing', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit_and_stay', false);\n                echo '</p>';\n            },\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($show, $form_args, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n            $generic_feed = \\Podlove\\Model\\Feed::first();\n\n            $podcast = \\Podlove\\Model\\Podcast::get();\n\n            $wrapper->string('title', [\n                'label' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n                'description' => sprintf(\n                    __('Title of your show as it appears in the feed. It is probably a good idea to include the name of your main podcast. For example, instead of \"Outtakes\", name the show \"%s | Outtakes\".', 'podlove-podcasting-plugin-for-wordpress'),\n                    $podcast->title\n                ),\n            ]);\n\n            $wrapper->string('slug', [\n                'label' => __('Slug', 'podlove-podcasting-plugin-for-wordpress').\\Podlove\\get_help_link('podlove_help_shows_slug'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n                'description' => 'Feed identifier. <span id=\"feed_subscribe_url_preview\" data-show-feed-base-url=\"'.get_site_url().'\" data-show-feed-slug=\"'.(isset($generic_feed) ? $generic_feed->slug : '').'\" data-show-preview-string=\"'.__('URL preview:', 'podlove-podcasting-plugin-for-wordpress').'\"></span>',\n            ]);\n\n            $wrapper->string('subtitle', [\n                'label' => __('Subtitle', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Subtitle of your show as it appears in the feed. Leave blank to default to your podcast\\'s subtitle.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'class' => 'regular-text podlove-check-input',\n                    'placeholder' => $podcast->subtitle,\n                ],\n            ]);\n\n            $wrapper->text('summary', [\n                'label' => __('Summary', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Summary of your show as it appears in the feed. Leave blank to default to your podcast\\'s summary.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'rows' => 3,\n                    'cols' => 40,\n                    'placeholder' => $podcast->summary,\n                    'class' => 'podlove-check-input',\n                ],\n            ]);\n\n            $wrapper->upload('image', [\n                'label' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n                'media_button_text' => __('Use Image for Show', 'podlove-podcasting-plugin-for-wordpress'),\n            ]);\n\n            $wrapper->select('language', [\n                'label' => __('Language', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => '',\n                'default' => get_bloginfo('language'),\n                'options' => \\Podlove\\Locale\\locales(),\n            ]);\n\n            $wrapper->select('category', [\n                'label' => __('iTunes Category', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => '',\n                'type' => 'select',\n                'options' => \\Podlove\\Itunes\\categories(),\n            ]);\n\n            do_action('podlove_show_form_end', $wrapper);\n        });\n    }\n\n    private function edit_template()\n    {\n        $show = Show::find_by_id($_REQUEST['show']);\n        echo '<h3>'.sprintf(__('Edit Show: %s', 'podlove-podcasting-plugin-for-wordpress'), $show->title).'</h3>';\n        $this->form_template($show, 'save');\n    }\n\n    private static function get_action_link($show, $title, $action = 'edit', $class = 'link')\n    {\n        return sprintf(\n            '<a href=\"?page=%s&amp;action=%s&amp;show=%s&amp;_podlove_nonce=%s\" class=\"%s\">'.$title.'</a>',\n            self::MENU_SLUG,\n            $action,\n            $show->id,\n            wp_create_nonce('update_shows'),\n            $class\n        );\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/settings/show_list_table.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows\\Settings;\n\nuse Podlove\\Modules\\Shows\\Model\\Show;\n\nclass ShowListTable extends \\Podlove\\List_Table\n{\n    public function __construct()\n    {\n        parent::__construct([\n            'singular' => 'show', // singular name of the listed records\n            'plural' => 'shows', // plural name of the listed records\n            'ajax' => false, // does this table support ajax?\n        ]);\n    }\n\n    public function column_title($show)\n    {\n        $link = function ($title, $action = 'edit') use ($show) {\n            return sprintf(\n                '<a href=\"?page=%s&action=%s&show=%s&_podlove_nonce=%s\">'.$title.'</a>',\n                Settings::MENU_SLUG,\n                $action,\n                $show->id,\n                wp_create_nonce('update_shows')\n            );\n        };\n\n        $actions = [\n            'edit' => $link(__('Edit', 'podlove-podcasting-plugin-for-wordpress')),\n            'delete' => $link(__('Delete', 'podlove-podcasting-plugin-for-wordpress'), 'confirm_delete'),\n        ];\n\n        return sprintf(\n            '%1$s %2$s',\n            $link($show->title),\n            $this->row_actions($actions)\n        );\n    }\n\n    public function column_image($show)\n    {\n        if ($show->image) {\n            return $show->image()->setWidth(64)->setHeight(64)->image();\n        }\n\n        return '';\n    }\n\n    public function column_episodes($show)\n    {\n        if ($term = get_term($show->id)) {\n            return $term->count;\n        }\n    }\n\n    public function column_show_feeds($show)\n    {\n        ?> <ul> <?php\nforeach (\\Podlove\\Model\\Feed::find_all_by_discoverable(1) as $feed) {\n    printf(\n        '<li><a href=\"%1$s\">%1$s</a></li>',\n        $feed->get_subscribe_url('shows', $show->id)\n    );\n} ?> </ul> <?php\n    }\n\n    public function get_columns()\n    {\n        return [\n            'title' => __('Show', 'podlove-podcasting-plugin-for-wordpress'),\n            'image' => __('Image', 'podlove-podcasting-plugin-for-wordpress'),\n            'episodes' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'show_feeds' => __('Subscribe URLs', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public function prepare_items()\n    {\n        // number of items per page\n        $per_page = get_user_meta(get_current_user_id(), 'podlove_shows_per_page', true);\n        if (empty($per_page)) {\n            $per_page = 10;\n        }\n\n        // define column headers\n        $columns = $this->get_columns();\n        $hidden = [];\n        $sortable = $this->get_sortable_columns();\n        $this->_column_headers = [$columns, $hidden, $sortable];\n\n        // retrieve data\n        $data = Show::all();\n\n        // get current page\n        $current_page = $this->get_pagenum();\n        // get total items\n        $total_items = count($data);\n        // extrage page for current page only\n        $data = array_slice($data, ($current_page - 1) * $per_page, $per_page);\n        // add items to table\n        $this->items = $data;\n\n        // register pagination options & calculations\n        $this->set_pagination_args([\n            'total_items' => $total_items,\n            'per_page' => $per_page,\n            'total_pages' => ceil($total_items / $per_page),\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/shows.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows;\n\nuse Podlove\\Modules\\Shows\\Model\\Show;\nuse Podlove\\Modules\\SubscribeButton\\Button;\n\nclass Shows extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Shows';\n    protected $module_description = 'Release specific episodes of a podcast as Shows.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('init', [$this, 'register_show_taxonomy']);\n        add_action('rest_api_init', [$this, 'api_init']);\n        add_action('add_meta_boxes', [$this, 'add_meta_box']);\n\n        add_action('podlove_register_settings_pages', function ($handle) {\n            new \\Podlove\\Modules\\Shows\\Settings\\Settings($handle);\n        });\n        add_action('admin_print_styles', [$this, 'scripts_and_styles']);\n\n        add_filter('podlove_feed_title', [$this, 'override_feed_title'], 20);\n        add_filter('podlove_feed_guid', [$this, 'override_feed_guid'], 20);\n        add_filter('podlove_feed_itunes_subtitle', [$this, 'override_feed_subtitle'], 5);\n        add_filter('podlove_feed_itunes_summary', [$this, 'override_feed_summary'], 5);\n        add_filter('podlove_rss_feed_description', [$this, 'override_feed_description'], 20);\n        add_filter('podlove_feed_itunes_image', [$this, 'override_feed_image'], 5);\n        add_filter('podlove_feed_itunes_image_url', [$this, 'override_feed_image_url'], 5);\n        add_filter('podlove_feed_language', [$this, 'override_feed_language'], 5);\n        add_filter('podlove_feed_itunes_category_id', [$this, 'override_feed_category'], 5);\n\n        add_filter('set-screen-option', function ($status, $option, $value) {\n            if ($option == 'podlove_shows_per_page') {\n                return $value;\n            }\n\n            return $status;\n        }, 10, 3);\n\n        add_filter('podlove_subscribe_button_data', [$this, 'override_subscribe_button'], 10, 3);\n        add_filter('podlove_subscribe_button_args', [$this, 'override_subscribe_button_args'], 10, 2);\n\n        add_action('podlove_subscribe_button_widget_settings_bottom', [$this, 'add_widget_settings'], 10, 2);\n        add_filter('podlove_subscribe_button_widget_settings_update', [$this, 'add_widget_settings_update'], 10, 3);\n\n        add_filter('podlove_ga_track_params', [$this, 'ga_track_params'], 10, 2);\n\n        // Template accessors (Provides episode.show and podcasts.shows)\n        \\Podlove\\Template\\Episode::add_accessor(\n            'show',\n            ['\\Podlove\\Modules\\Shows\\TemplateExtensions', 'accessorEpisodesShow'],\n            5\n        );\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'shows',\n            ['\\Podlove\\Modules\\Shows\\TemplateExtensions', 'accessorPodcastShows'],\n            4\n        );\n    }\n\n    public function add_widget_settings($widget, $instance)\n    {\n        $selected_show = isset($instance['show']) ? $instance['show'] : ''; ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $widget->get_field_id('show'); ?>\">\n\t\t\t\t<?php _e('Show', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</label>\n\t\t\t<select class=\"widefat\" id=\"<?php echo $widget->get_field_id('show'); ?>\" name=\"<?php echo $widget->get_field_name('show'); ?>\">\n\t\t\t\t<option value=\"0\" <?php selected($selected_show, 0); ?>><?php _e('Podcast', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t<?php foreach (Show::all() as $show) { ?>\n\t\t\t\t\t<option value=\"<?php echo esc_attr($show->slug); ?>\" <?php selected($selected_show, $show->slug); ?>><?php echo $show->title; ?></option>\n\t\t\t\t<?php } ?>\n\t\t\t</select>\n\t\t</p>\n\t\t<?php\n    }\n\n    public function add_widget_settings_update($instance, $new_instance, $old_instance)\n    {\n        $instance['show'] = !empty($new_instance['show']) ? wp_strip_all_tags($new_instance['show']) : '';\n\n        return $instance;\n    }\n\n    public function override_subscribe_button($data, $args, $podcast)\n    {\n        if (!isset($args['show']) || !$args['show']) {\n            return $data;\n        }\n\n        $show = Show::find_one_term_by_property('slug', $args['show']);\n\n        if (!$show) {\n            return $data;\n        }\n\n        $feeds = Button::feeds(\n            $podcast->feeds(['only_discoverable' => true]),\n            'shows',\n            $show->id\n        );\n\n        $show_data = [\n            'title' => $show->title,\n            'subtitle' => $show->subtitle,\n            'description' => $show->summary,\n            'cover' => $show->image,\n            'feeds' => $feeds,\n        ];\n\n        // only override nonempty fields\n        foreach (array_keys($show_data) as $key) {\n            if ($show_data[$key] && !empty($show_data[$key])) {\n                $data[$key] = $show_data[$key];\n            }\n        }\n\n        return $data;\n    }\n\n    public function override_subscribe_button_args($args, $podcast)\n    {\n        if (!isset($args['show']) || !$args['show']) {\n            return $args;\n        }\n\n        $show = Show::find_one_term_by_property('slug', $args['show']);\n\n        if (!$show) {\n            return $args;\n        }\n\n        if ($show->language) {\n            $args['language'] = \\Podlove\\Modules\\SubscribeButton\\Button::language($show->language);\n        }\n\n        return $args;\n    }\n\n    public function ga_track_params($params, $episode)\n    {\n        $show = Show::find_one_by_episode_id($episode->id);\n\n        if ($show) {\n            $params['cg1'] = $show->title;\n        }\n\n        return $params;\n    }\n\n    public function add_meta_box()\n    {\n        add_meta_box(\n            // $id\n            'podlove_podcast_show',\n            // $title\n            __('Show', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            [$this, 'episode_show_meta_box'],\n            // $page\n            'podcast',\n            // $context\n            'normal',\n            // $priority\n            'low'\n        );\n    }\n\n    public function register_show_taxonomy()\n    {\n        register_taxonomy(\n            'shows',\n            'podcast',\n            [\n                'label' => __('Show', 'podlove-podcasting-plugin-for-wordpress'),\n                'rewrite' => ['slug' => 'show'],\n                'show_ui' => false,\n                'show_in_menu' => false,\n                'show_in_quick_edit' => false,\n                'show_in_rest' => false,\n                'hierarchical' => false,\n                'show_admin_column' => false,\n            ]\n        );\n    }\n\n    public function api_init()\n    {\n        $api = new REST_API();\n        $api->register_routes();\n    }\n\n    public function override_feed_title($title)\n    {\n        return self::get_feed_modification($title, 'title');\n    }\n\n    public function override_feed_guid($guid)\n    {\n        return self::get_feed_modification($guid, 'guid');\n    }\n\n    public function override_feed_subtitle($subtitle)\n    {\n        return self::get_feed_modification($subtitle, 'subtitle');\n    }\n\n    public function override_feed_description($description)\n    {\n        return self::get_feed_modification($description, 'description');\n    }\n\n    public function override_feed_summary($summary)\n    {\n        return self::get_feed_modification($summary, 'summary');\n    }\n\n    public function override_feed_image($image)\n    {\n        return self::get_feed_modification($image, 'image');\n    }\n\n    public function override_feed_image_url($url)\n    {\n        return self::get_feed_modification($url, 'image_url');\n    }\n\n    public function override_feed_language($language)\n    {\n        return self::get_feed_modification($language, 'language');\n    }\n\n    public function override_feed_category($category_id)\n    {\n        return self::get_feed_modification($category_id, 'category');\n    }\n\n    /**\n     * Show selection meta box for the Episode UI.\n     */\n    public function episode_show_meta_box()\n    {\n        ?>\n      <div data-client=\"podlove\">\n        <podlove-show-select></podlove-show-select>\n      </div>\n\t\t  <?php\n    }\n\n    public function scripts_and_styles()\n    {\n        if (filter_input(INPUT_GET, 'page') !== 'podlove_shows_settings') {\n            return;\n        }\n\n        wp_enqueue_script(\n            'podlove_shows_admin_script',\n            $this->get_module_url().'/js/admin.js',\n            ['jquery'],\n            \\Podlove\\get_plugin_header('Version')\n        );\n    }\n\n    public static function set_show_for_episode($post_id, $show_slug)\n    {\n        if (self::is_valid_existing_slug($show_slug)) {\n            wp_set_object_terms($post_id, $show_slug, 'shows');\n        } else {\n            wp_delete_object_term_relationships($post_id, 'shows');\n        }\n    }\n\n    public static function is_valid_existing_slug($slug)\n    {\n        $valid_slugs = array_map(function ($show) { return $show->slug; }, Show::all());\n\n        return in_array($slug, $valid_slugs);\n    }\n\n    /*\n     * Handles the feed modifications\n     *\n     * @param string $previous_value Previous value\n     * @param string $new_value Used to replace $previous_value\n     */\n    private static function get_feed_modification($previous_value, $new_value)\n    {\n        global $wp_query;\n\n        if (isset($wp_query->query_vars['shows'])) {\n            $show = Show::find_one_term_by_property('slug', $wp_query->query_vars['shows']);\n\n            switch ($new_value) {\n                case 'title':\n                    return $show->title;\n\n                    break;\n                case 'subtitle':\n                    if ($show->subtitle) {\n                        return \\Podlove\\Feeds\\get_xml_text_node('itunes:subtitle', $show->subtitle);\n                    }\n\n                    break;\n                case 'description':\n                    if ($show->summary) {\n                        return $show->summary;\n                    }\n\n                    if ($show->subtitle) {\n                        return $show->subtitle;\n                    }\n\n                    break;\n                case 'summary':\n                    if ($show->summary) {\n                        return \\Podlove\\Feeds\\get_xml_text_node('itunes:summary', $show->summary);\n                    }\n\n                    break;\n                case 'language':\n                    if ($show->language) {\n                        return $show->language;\n                    }\n\n                    break;\n                case 'image':\n                    if ($show->image) {\n                        return \\Podlove\\Feeds\\get_xml_itunesimage_node($show->image);\n                    }\n\n                    break;\n                case 'image_url':\n                    return $show->image;\n\n                    break;\n                case 'category':\n                    return $show->category;\n                case 'guid':\n                    if ($show->guid) {\n                        return \\Podlove\\Feeds\\get_xml_text_node('podcast:guid', $show->guid);\n                    }\n\n                    break;\n            }\n        }\n\n        return $previous_value;\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/template/show.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Show Template Wrapper.\n *\n * @templatetag show\n */\nclass Show extends Wrapper\n{\n    private $show;\n\n    public function __construct(\\Podlove\\Modules\\Shows\\Model\\Show $show)\n    {\n        $this->show = $show;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->show->title;\n    }\n\n    /**\n     * Subtitle.\n     *\n     * @accessor\n     */\n    public function subtitle()\n    {\n        return $this->show->subtitle;\n    }\n\n    /**\n     * Summary.\n     *\n     * @accessor\n     */\n    public function summary()\n    {\n        return $this->show->summary;\n    }\n\n    /**\n     * Slug.\n     *\n     * @accessor\n     */\n    public function slug()\n    {\n        return $this->show->slug;\n    }\n\n    /**\n     * Language.\n     *\n     * @accessor\n     */\n    public function language()\n    {\n        return $this->show->language;\n    }\n\n    /**\n     * Image.\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return new \\Podlove\\Template\\Image($this->show->image());\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->show];\n    }\n}\n"
  },
  {
    "path": "lib/modules/shows/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Shows;\n\nclass TemplateExtensions\n{\n    /**\n     * List of all Podcast shows.\n     *\n     * **Examples**\n     *\n     * ```\n     * This podcast features several shows:\n     * <ul>\n     *     {% for show in podcast.shows %}\n     *      <li>{{ show.title }}</li>\n     *  {% endfor %}\n     * </ul>\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.shows\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $episode\n     */\n    public static function accessorPodcastShows($return, $method_name, $episode)\n    {\n        return $episode->with_blog_scope(function () {\n            return array_map(function (Model\\Show $show) {\n                return new Template\\Show($show);\n            }, Model\\Show::all());\n        });\n    }\n\n    /**\n     * Episode Show.\n     *\n     * **Examples**\n     *\n     * ```\n     * This episode is part of the Show: {{ episode.show.title }} which deals with\n     * {{ episode.show.summary }}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.show\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $episode\n     */\n    public static function accessorEpisodesShow($return, $method_name, $episode)\n    {\n        return $episode->with_blog_scope(function () use ($episode) {\n            if ($show = Model\\Show::find_one_by_episode_id($episode->id)) {\n                return new Template\\Show($show);\n            }\n\n            return null;\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/slack_shownotes/message.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SlackShownotes;\n\nclass Message\n{\n    public static function extract_links($message)\n    {\n        preg_match_all('/<(http[^>]*)>/', $message['text'], $links);\n\n        return array_reduce($links[1], function ($agg, $url) use ($message) {\n            $url_segments = explode('|', $url);\n            $canonical_url = $url_segments[0];\n\n            $agg[] = [\n                'link' => $canonical_url,\n                'title' => self::get_url_title_via_attachment($canonical_url, $message),\n                'source' => self::get_source_via_attachment($canonical_url, $message),\n            ];\n\n            return $agg;\n        }, []);\n    }\n\n    public static function get_url_title_via_attachment($url, $message)\n    {\n        if (!isset($message['attachments']) || !count($message['attachments'])) {\n            return null;\n        }\n\n        $matching_attachments = array_filter($message['attachments'], function ($attachment) use ($url) {\n            return $url == $attachment['original_url'];\n        });\n\n        if (!count($matching_attachments)) {\n            return null;\n        }\n\n        return array_values($matching_attachments)[0]['title'];\n    }\n\n    public static function get_source_via_attachment($url, $message)\n    {\n        $fallback = self::get_short_domain_from_url($url);\n\n        if (!isset($message['attachments']) || !count($message['attachments'])) {\n            return $fallback;\n        }\n\n        $matching_attachments = array_filter($message['attachments'], function ($attachment) use ($url) {\n            return $url == $attachment['original_url'];\n        });\n\n        if (!count($matching_attachments)) {\n            return $fallback;\n        }\n\n        return array_values($matching_attachments)[0]['service_name'];\n    }\n\n    public static function get_short_domain_from_url($url)\n    {\n        $host = wp_parse_url($url)['host'];\n\n        return preg_replace('/^www\\./', '', $host);\n    }\n}\n"
  },
  {
    "path": "lib/modules/slack_shownotes/settings/settings.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SlackShownotes\\Settings;\n\nclass Settings\n{\n    public const MENU_SLUG = 'podlove_slackshownotes_settings';\n\n    public function __construct($handle)\n    {\n        $pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Slacknotes', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Slacknotes', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'edit_posts',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Slacknotes', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\n\t\t\t<?php if (\\Podlove\\Modules\\SlackShownotes\\Slack_Shownotes::instance()->get_api_token()) { ?>\n\t\t\t\t<div id=\"slacknotes-app\">\n\t\t\t\t\t<slacknotes></slacknotes>\n\t\t\t\t</div>\n\t\t\t<?php } else { ?>\n\t\t\t\t<div class=\"card\">\n\t\t\t\t\t<h2 class=\"title\"><?php echo __('API Token Required', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<?php echo __('You need to configure a Slack API token before you can use Slacknotes.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</p>\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<a href=\"<?php echo admin_url('admin.php?page=podlove_settings_modules_handle#slack_shownotes'); ?>\" class=\"button button-primary\"><?php echo __('Go to Module Settings', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t<?php } ?>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/slack_shownotes/slack_shownotes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SlackShownotes;\n\nclass Slack_Shownotes extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Slacknotes';\n    protected $module_description = 'Extract link lists from a Slack channel to be used in show notes.';\n    protected $module_group = 'web publishing';\n\n    public function load()\n    {\n        add_action('podlove_register_settings_pages', function ($handle) {\n            new \\Podlove\\Modules\\SlackShownotes\\Settings\\Settings($handle);\n        });\n\n        add_action('rest_api_init', [$this, 'api_init']);\n\n        add_action('init', [$this, 'register_settings']);\n    }\n\n    public function api_init()\n    {\n        register_rest_route('podlove/v1', 'slacknotes/channels', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_get_channels'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n\n        register_rest_route('podlove/v1', 'slacknotes/resolve_url', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_resolve_url'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n\n        register_rest_route('podlove/v1', 'slacknotes/(?P<channel>[a-zA-Z0-9]+)/messages', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_get_messages'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n\n        register_rest_route('podlove/v2', 'slacknotes/channels', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_get_channels'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n\n        register_rest_route('podlove/v2', 'slacknotes/resolve_url', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_resolve_url'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n\n        register_rest_route('podlove/v2', 'slacknotes/(?P<channel>[a-zA-Z0-9]+)/messages', [\n            'methods' => 'GET',\n            'callback' => [$this, 'api_get_messages'],\n            'permission_callback' => [$this, 'permission_check'],\n        ]);\n    }\n\n    public function api_get_channels(\\WP_REST_Request $request)\n    {\n        if (!$this->get_api_token()) {\n            return new \\WP_Error(\n                'podlove_slacknotes_no_token',\n                'Slack api token is missing',\n                ['status' => 404]\n            );\n        }\n\n        $data = $this->get_channels();\n\n        return new \\WP_REST_Response($data);\n    }\n\n    public function api_resolve_url(\\WP_REST_Request $request)\n    {\n        $url = $request->get_param('url');\n\n        if (!$url) {\n            return new \\WP_REST_Response(['success' => false]);\n        }\n\n        $response = self::fetch_url_meta($url);\n\n        return new \\WP_REST_Response($response);\n    }\n\n    public function api_get_messages(\\WP_REST_Request $request)\n    {\n        $channel_id = $request->get_param('channel');\n        $date_from = $request->get_param('date_from');\n        $date_to = $request->get_param('date_to');\n        $data = $this->get_messages($channel_id, $date_from, $date_to);\n\n        return new \\WP_REST_Response($data);\n    }\n\n    public function register_settings()\n    {\n        if (!self::is_module_settings_page()) {\n            return;\n        }\n\n        $this->register_option('slack_api_token', 'password', [\n            'label' => __('Slack OAuth Access Token', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '<a href=\"https://docs.podlove.org/podlove-publisher/modules/slacknotes/\" target=\"_blank\">'.__('Follow guide on how to get the token.', 'podlove-podcasting-plugin-for-wordpress').'</a>',\n            'html' => ['class' => 'regular-text'],\n        ]);\n    }\n\n    public function get_api_token()\n    {\n        return $this->get_module_option('slack_api_token');\n    }\n\n    public function get_channels()\n    {\n        $curl = new \\Podlove\\HTTP\\Curl();\n        $curl->request(\n            'https://slack.com/api/conversations.list',\n            ['headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->get_api_token(),\n            ]]\n        );\n\n        $response = $curl->get_response();\n\n        if (!$curl->isSuccessful()) {\n            return [];\n        }\n\n        $result = json_decode($response['body'], true);\n\n        if (!$result['ok']) {\n            return [];\n        }\n\n        return array_map(function ($channel) {\n            return [\n                'id' => $channel['id'],\n                'name' => $channel['name'],\n            ];\n        }, $result['channels']);\n    }\n\n    public function get_messages($channel_id, $date_from, $date_to)\n    {\n        $api_url = 'https://slack.com/api/conversations.history';\n\n        $api_args = ['channel' => $channel_id];\n\n        if ($date_from && $date_to) {\n            $api_args['oldest'] = (int) $date_from;\n            $api_args['latest'] = (int) $date_to;\n        }\n\n        // todo: use has_more field for paging\n        $api_args['limit'] = 1000;\n\n        $url = add_query_arg($api_args, $api_url);\n\n        $curl = new \\Podlove\\HTTP\\Curl();\n        $curl->request(\n            $url,\n            ['headers' => [\n                'Content-type' => 'application/json',\n                'Authorization' => 'Bearer '.$this->get_api_token(),\n            ]]\n        );\n\n        $response = $curl->get_response();\n\n        if (!$curl->isSuccessful()) {\n            echo 'curl failed'.\"\\n\";\n\n            return [];\n        }\n\n        $result = json_decode($response['body'], true);\n\n        if (!$result['ok']) {\n            echo 'result not ok'.\"\\n\";\n            if (isset($result['error'])) {\n                echo $result['error'].\"\\n\";\n            }\n\n            return [];\n        }\n\n        return array_map(function ($message) {\n            return [\n                'raw_slack_message' => $message,\n                'links' => Message::extract_links($message),\n            ];\n        }, $result['messages']);\n    }\n\n    /**\n     * Fetches title and effective URL for URL.\n     *\n     * Prefers \"og:title\", falls back to \"title\".\n     *\n     * @param string $url\n     *\n     * @return string\n     */\n    public static function fetch_url_meta($url)\n    {\n        $response = [\n            'url' => $url,\n            'title' => '',\n        ];\n\n        $safe_response = wp_safe_remote_get($url);\n\n        $final_url = array_map(\n            function ($entry) { return $entry->url; },\n            $safe_response['http_response']->get_response_object()->history\n        )[0];\n\n        if ($final_url) {\n            $response['url'] = $final_url;\n        }\n\n        $html = $safe_response['body'];\n\n        if (!is_wp_error($safe_response)) {\n            $dom = new \\DOMDocument();\n            $loaded = $dom->loadHTML($html, LIBXML_NOERROR);\n\n            if (!$loaded) {\n                return $response;\n            }\n\n            foreach ($dom->getElementsByTagName('meta') as $node) {\n                if ($node->getAttribute('property') == 'og:title') {\n                    $response['title'] = $node->getAttribute('content');\n\n                    return $response;\n                }\n            }\n\n            foreach ($dom->getElementsByTagName('title') as $node) {\n                $response['title'] = $node->nodeValue;\n\n                return $response;\n            }\n        }\n\n        return $response;\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/admin.css",
    "content": ".podlove-service-link {\n\tdisplay: none;\n\tcursor: pointer;\n}\n\n.podlove-contributor-list-social-logo {\n\twidth: 16px;\n}\n\nul.podlove-contributor-social-list, ul.podlove-contributor-social-list li {\n\tmargin: 0px;\n\tpadding: 0px;\n}"
  },
  {
    "path": "lib/modules/social/data/services.yml",
    "content": "--- # services:\n- title: '500px'\n  name: '500px'\n  category: 'social'\n  description: '500px Account'\n  logo: '500px.png'\n  url_scheme: 'https://500px.com/%account-placeholder%'\n\n- title: 'Bandcamp'\n  name: 'bandcamp'\n  category: 'social'\n  description: 'Bandcamp URL'\n  logo: 'bandcamp.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Bitbucket'\n  name: 'bitbucket'\n  category: 'social'\n  description: 'Bitbucket Account'\n  logo: 'bitbucket.png'\n  url_scheme: 'https://bitbucket.org/%account-placeholder%'\n\n- title: 'Bluesky'\n  name: 'bluesky'\n  category: 'social'\n  description: 'Bluesky Account'\n  logo: 'bluesky.svg'\n  url_scheme: 'https://bsky.app/profile/%account-placeholder%'\n\n- title: 'Codeberg'\n  name: 'codeberg'\n  category: 'social'\n  description: 'Codeberg Account'\n  logo: 'codeberg.svg'\n  url_scheme: 'https://codeberg.org/%account-placeholder%'\n\n- title: 'DeviantART'\n  name: 'deviantart'\n  category: 'social'\n  description: 'DeviantART Account'\n  logo: 'deviantart.png'\n  url_scheme: 'https://%account-placeholder%.deviantart.com/'\n\n- title: 'Diaspora'\n  name: 'diaspora'\n  category: 'social'\n  description: 'Diaspora URL'\n  logo: 'diaspora.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Dribbble'\n  name: 'dribbble'\n  category: 'social'\n  description: 'Dribbble Account'\n  logo: 'dribbble.png'\n  url_scheme: 'https://dribbble.com/%account-placeholder%'\n\n- title: 'Facebook'\n  name: 'facebook'\n  category: 'social'\n  description: 'Facebook Account'\n  logo: 'facebook.png'\n  url_scheme: 'https://facebook.com/%account-placeholder%'\n\n- title: 'Fediverse'\n  name: 'fediverse'\n  category: 'social'\n  description: 'Fediverse Account (URL)'\n  logo: 'fediverse.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Flattr'\n  name: 'flattr'\n  category: 'social'\n  description: 'Flattr Username'\n  logo: 'flattr.png'\n  url_scheme: 'https://flattr.com/profile/%account-placeholder%'\n\n- title: 'Flickr'\n  name: 'flickr'\n  category: 'social'\n  description: 'Flickr Account'\n  logo: 'flickr.png'\n  url_scheme: 'https://secure.flickr.com/photos/%account-placeholder%'\n\n- title: 'Friendica'\n  name: 'friendica'\n  category: 'social'\n  description: 'Friendica Account (URL)'\n  logo: 'friendica.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'GitHub'\n  name: 'github'\n  category: 'social'\n  description: 'GitHub Account'\n  logo: 'github.png'\n  url_scheme: 'https://github.com/%account-placeholder%'\n\n- title: 'Google+'\n  name: 'google+'\n  category: 'social'\n  description: 'Google+ URL'\n  logo: 'googleplus.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Instagram'\n  name: 'instagram'\n  category: 'social'\n  description: 'Instagram Account'\n  logo: 'instagram.png'\n  url_scheme: 'https://instagram.com/%account-placeholder%'\n\n- title: 'Jabber'\n  name: 'jabber'\n  category: 'social'\n  description: 'Jabber ID'\n  logo: 'jabber.png'\n  url_scheme: 'jabber:%account-placeholder%'\n\n- title: 'Last.fm'\n  name: 'last.fm'\n  category: 'social'\n  description: 'Last.fm Account'\n  logo: 'lastfm.png'\n  url_scheme: 'https://www.last.fm/user/%account-placeholder%'\n\n- title: 'Mastodon'\n  name: 'mastodon'\n  category: 'social'\n  description: 'Mastodon Account (URL)'\n  logo: 'mastodon.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Matrix'\n  name: 'matrix'\n  category: 'social'\n  description: 'Matrix User ID (@user:domain.org)'\n  logo: 'matrix.png'\n  url_scheme: 'https://matrix.to/#/%account-placeholder%'\n\n- title: 'OpenStreetMap'\n  name: 'openstreetmap'\n  category: 'social'\n  description: 'OpenStreetMap Account'\n  logo: 'openstreetmap.png'\n  url_scheme: 'https://www.openstreetmap.org/user/%account-placeholder%'\n\n- title: 'Liberapay'\n  name: 'liberapay'\n  category: 'donation'\n  description: 'Liberapay Account'\n  logo: 'liberapay.png'\n  url_scheme: 'https://liberapay.com/%account-placeholder%'\n\n- title: 'Linkedin'\n  name: 'linkedin'\n  category: 'social'\n  description: 'Linkedin URL'\n  logo: 'linkedin.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Miiverse'\n  name: 'miiverse'\n  category: 'social'\n  description: 'Miiverse Account'\n  logo: 'miiverse.png'\n  url_scheme: 'https://miiverse.nintendo.net/users/%account-placeholder%'\n\n- title: 'Pinboard'\n  name: 'pinboard'\n  category: 'social'\n  description: 'Pinboard Account'\n  logo: 'pinboard.png'\n  url_scheme: 'https://pinboard.in/u:%account-placeholder%'\n\n- title: 'Pinterest'\n  name: 'pinterest'\n  category: 'social'\n  description: 'Pinterest Account'\n  logo: 'pinterest.png'\n  url_scheme: 'https://www.pinterest.com/%account-placeholder%'\n\n- title: 'Playstation Network'\n  name: 'playstation network'\n  category: 'social'\n  description: 'Playstation Network Account'\n  logo: 'psn.png'\n  url_scheme: 'https://psnprofiles.com/%account-placeholder%'\n\n- title: 'Prezi'\n  name: 'prezi'\n  category: 'social'\n  description: 'Prezis'\n  logo: 'prezi.png'\n  url_scheme: 'https://prezi.com/user/%account-placeholder%'\n\n- title: 'SlideShare'\n  name: 'slideshare'\n  category: 'social'\n  description: 'SlideShare Account'\n  logo: 'slideshare.png'\n  url_scheme: 'https://www.slideshare.net/%account-placeholder%'\n\n- title: 'Skype'\n  name: 'skype'\n  category: 'social'\n  description: 'Skype Account'\n  logo: 'skype.png'\n  url_scheme: 'skype:%account-placeholder%'\n\n- title: 'Soundcloud'\n  name: 'soundcloud'\n  category: 'social'\n  description: 'Soundcloud Account'\n  logo: 'soundcloud.png'\n  url_scheme: 'https://soundcloud.com/%account-placeholder%'\n\n- title: 'Soup'\n  name: 'soup'\n  category: 'social'\n  description: 'Soup Account'\n  logo: 'soup.png'\n  url_scheme: 'http://%account-placeholder%.soup.io'\n\n- title: 'Spreaker'\n  name: 'spreaker'\n  category: 'social'\n  description: 'Spreaker Account'\n  logo: 'spreaker.png'\n  url_scheme: 'https://www.spreaker.com/user/%account-placeholder%'\n\n- title: 'Steam'\n  name: 'steam'\n  category: 'social'\n  description: 'Steam Account'\n  logo: 'steam.png'\n  url_scheme: 'https://steamcommunity.com/id/%account-placeholder%'\n\n- title: 'Strava'\n  name: 'strava'\n  category: 'social'\n  description: 'Strava Account'\n  logo: 'strava.png'\n  url_scheme: 'https://www.strava.com/athletes/%account-placeholder%'\n\n- title: 'Trakt'\n  name: 'trakt'\n  category: 'social'\n  description: 'Trakt account'\n  logo: 'trakt.png'\n  url_scheme: 'https://trakt.tv/users/%account-placeholder%'\n\n- title: 'Tumblr'\n  name: 'tumblr'\n  category: 'social'\n  description: 'Tumblr Account'\n  logo: 'tumblr.png'\n  url_scheme: 'https://%account-placeholder%.tumblr.com/'\n\n- title: 'Twitch'\n  name: 'twitch'\n  category: 'social'\n  description: 'Twitch Account'\n  logo: 'twitch.png'\n  url_scheme: 'https://www.twitch.tv/%account-placeholder%'\n\n- title: 'Twitter'\n  name: 'twitter'\n  category: 'social'\n  description: 'Twitter Account'\n  logo: 'twitter.png'\n  url_scheme: 'https://twitter.com/%account-placeholder%'\n\n- title: 'Vimeo'\n  name: 'vimeo'\n  category: 'social'\n  description: 'Vimeo Account'\n  logo: 'vimeo.png'\n  url_scheme: 'https://vimeo.com/%account-placeholder%'\n\n- title: 'Website'\n  name: 'website'\n  category: 'social'\n  description: 'Website URL'\n  logo: 'www.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Xbox Live'\n  name: 'xbox live'\n  category: 'social'\n  description: 'Xbox Live Account'\n  logo: 'xbox.png'\n  url_scheme: 'https://live.xbox.com/profile?gamertag=%account-placeholder%'\n\n- title: 'Xing'\n  name: 'xing'\n  category: 'social'\n  description: 'Xing URL'\n  logo: 'xing.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'YouTube (User)'\n  name: 'youtube'\n  category: 'social'\n  description: 'YouTube User'\n  logo: 'youtube.png'\n  url_scheme: 'https://www.youtube.com/user/%account-placeholder%'\n\n- title: 'YouTube (Channel)'\n  name: 'youtube-channel'\n  category: 'social'\n  description: 'YouTube Channel'\n  logo: 'youtube.png'\n  url_scheme: 'https://www.youtube.com/channel/%account-placeholder%'\n\n- title: 'Amazon Wishlist'\n  name: 'amazon wishlist'\n  category: 'donation'\n  description: 'Amazon Wishlist URL'\n  logo: 'amazonwishlist.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Amazon'\n  name: 'amazon'\n  category: 'internal'\n  logo: 'amazon.svg'\n  url_scheme: 'https://www.amazon.de'\n\n- title: 'Amazon'\n  name: 'amazon'\n  category: 'internal'\n  logo: 'amazon.svg'\n  url_scheme: 'https://www.amazon.com'\n\n- title: 'Wikipedia'\n  name: 'wikipedia'\n  category: 'internal'\n  logo: 'wikipedia.svg'\n  url_scheme: 'https://de.wikipedia.org'\n\n- title: 'Wikipedia'\n  name: 'wikipedia'\n  category: 'internal'\n  logo: 'wikipedia.svg'\n  url_scheme: 'https://en.wikipedia.org'\n\n- title: 'Bitcoin'\n  name: 'bitcoin'\n  category: 'donation'\n  description: 'Bitcoin Wallet Address'\n  logo: 'bitcoin.png'\n  url_scheme: 'bitcoin:%account-placeholder%'\n\n- title: 'Dogecoin'\n  name: 'dogecoin'\n  category: 'donation'\n  description: 'Dogecoin Wallet Address'\n  logo: 'dogecoin.png'\n  url_scheme: 'dogecoin:%account-placeholder%'\n\n- title: 'Flattr'\n  name: 'flattr'\n  category: 'donation'\n  description: 'Flattr Username'\n  logo: 'flattr.png'\n  url_scheme: 'https://flattr.com/profile/%account-placeholder%'\n\n- title: 'Generic Wishlist'\n  name: 'generic wishlist'\n  category: 'donation'\n  description: 'Wishlist URL'\n  logo: 'genericwishlist.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'Litecoin'\n  name: 'litecoin'\n  category: 'donation'\n  description: 'Litecoin Wallet Address'\n  logo: 'litecoin.png'\n  url_scheme: 'litecoin:%account-placeholder%'\n\n- title: 'Paypal'\n  name: 'paypal'\n  category: 'donation'\n  description: 'Paypal Button ID'\n  logo: 'paypal.png'\n  url_scheme: 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=%account-placeholder%'\n\n- title: 'Paypal.me'\n  name: 'paypalme'\n  category: 'donation'\n  description: 'Paypal.me Account'\n  logo: 'paypal.png'\n  url_scheme: 'https://paypal.me/%account-placeholder%'\n\n- title: 'Steam Wishlist'\n  name: 'steam wishlist'\n  category: 'donation'\n  description: 'Steam Account'\n  logo: 'steam.png'\n  url_scheme: 'https://steamcommunity.com/id/%account-placeholder%/wishlist'\n\n- title: 'Thomann Wishlist'\n  name: 'thomann wishlist'\n  category: 'donation'\n  description: 'Thomann Wishlist URL'\n  logo: 'thomann.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'about.me'\n  name: 'about.me'\n  category: 'social'\n  description: 'about.me Account'\n  logo: 'aboutme.png'\n  url_scheme: 'https://about.me/%account-placeholder%'\n\n- title: 'Gittip'\n  name: 'gittip'\n  category: 'donation'\n  description: 'Gittip Account'\n  logo: 'gittip.png'\n  url_scheme: 'https://www.gittip.com/%account-placeholder%'\n\n- title: 'Auphonic Credits'\n  name: 'auphonic credits'\n  category: 'donation'\n  description: 'Auphonic Account'\n  logo: 'auphonic.png'\n  url_scheme: 'https://auphonic.com/donate_credits?user=%account-placeholder%'\n\n- title: 'Foursquare'\n  name: 'foursquare'\n  category: 'social'\n  description: 'Foursquare Account'\n  logo: 'foursquare.png'\n  url_scheme: 'https://foursquare.com/%account-placeholder%'\n\n- title: 'ResearchGate'\n  name: 'researchgate'\n  category: 'social'\n  description: 'ResearchGate URL'\n  logo: 'researchgate.png'\n  url_scheme: '%account-placeholder%'\n\n- title: 'ORCiD'\n  name: 'orcid'\n  category: 'social'\n  description: 'ORCiD'\n  logo: 'orcid.png'\n  url_scheme: 'https://orcid.org/%account-placeholder%'\n\n- title: 'Patreon'\n  name: 'patreon'\n  category: 'social'\n  description: 'Patreon'\n  logo: 'patreon.png'\n  url_scheme: 'https://www.patreon.com/%account-placeholder%'\n\n- title: 'Steady'\n  name: 'steady'\n  category: 'donation'\n  description: 'Steady'\n  logo: 'steady.png'\n  url_scheme: 'https://steadyhq.com/%account-placeholder%'\n\n- title: 'Scopus'\n  name: 'scopus'\n  category: 'social'\n  description: 'Scopus Author ID'\n  logo: 'scopus.png'\n  url_scheme: 'https://www.scopus.com/authid/detail.url?authorId=%account-placeholder%'\n\n- title: 'Email'\n  name: 'email'\n  category: 'social'\n  description: 'Email'\n  logo: 'email.png'\n  url_scheme: 'mailto:%account-placeholder%'\n\n- title: 'Letterboxd'\n  name: 'letterboxd'\n  category: 'social'\n  description: 'Letterboxd Username'\n  logo: 'letterboxd.png'\n  url_scheme: 'https://letterboxd.com/%account-placeholder%'\n\n- title: 'Untappd'\n  name: 'untappd'\n  category: 'social'\n  description: 'Untappd Username'\n  logo: 'untappd.png'\n  url_scheme: 'https://untappd.com/user/%account-placeholder%'\n"
  },
  {
    "path": "lib/modules/social/jobs/podcast_import_contributor_services_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportContributorServicesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Contributor Services';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Contributor Services';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Social\\Model\\ContributorService';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'contributorService';\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/jobs/podcast_import_services_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportServicesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Services';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Services';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Social\\Model\\Service';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'service';\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/jobs/podcast_import_show_services_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass PodcastImportShowServicesJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Show Services';\n    }\n\n    public static function description()\n    {\n        return 'Imports Podcast Show Services';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return '\\Podlove\\Modules\\Social\\Model\\ShowService';\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'showService';\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/js/admin.js",
    "content": "(function($) {\n\n\tfunction update_chosen() {\n\t\t$(\".chosen\").chosen();\n\t\t$(\".chosen-image\").chosenImage();\n\t}\n\n\tfunction fetch_service(service_id, category) {\n\t\tservice_id = parseInt(service_id, 10);\n\n\t\tif (!category) \n\t\t\treturn undefined;\n\n\t\treturn $.grep(PODLOVE.Social[category].services, function(service, index) {\n\t\t\treturn parseInt(service.id, 10) === service_id;\n\t\t})[0]; // Using [0] as the returned element has multiple indexes\n\t}\n\n\tfunction service_dropdown_handler() {\n\t\t$(document).on('change', 'select.podlove-service-dropdown', function() {\n\t\t\tvar row = $(this).closest(\"tr\");\n\t\t\tvar i = $(this).closest(\"tr\").index();\n\t\t\tvar category = $(this).closest(\".podlove_social_wrapper\").data(\"category\");\n\t\t\tvar service = fetch_service(this.value, category);\n\n\t\t\t// Check for empty contributors / for new field\n\t\t\tif( typeof service === 'undefined' ) {\n\t\t\t\trow.find(\".podlove-logo-column\").html(\"\"); // Empty avatar column and hide edit button\n\t\t\t\trow.find(\".podlove-service-edit\").hide();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Setting data attribute and avatar field\n\t\t\trow.data(\"service-id\", service.id);\n\t\t\t// Renaming all corresponding elements after the contributor has changed \n\t\t\trow.find(\".podlove-service-dropdown\").attr(\"name\", PODLOVE.Social[category].form_base_name + \"[\" + i + \"]\" + \"[\" + service.id + \"]\" + \"[id]\");\n\t\t\trow.find(\".podlove-service-value\").attr(\"name\", PODLOVE.Social[category].form_base_name + \"[\" + i + \"]\" + \"[\" + service.id + \"]\" + \"[value]\");\n\t\t\trow.find(\".podlove-service-value\").attr(\"placeholder\", service.description);\n\t\t\trow.find(\".podlove-service-value\").attr(\"title\", service.description);\n\t\t\trow.find(\".podlove-service-link\").data(\"service-url-scheme\", service.url_scheme);\n\t\t\trow.find(\".podlove-service-title\").attr(\"name\", PODLOVE.Social[category].form_base_name + \"[\" + i + \"]\" + \"[\" + service.id + \"]\" + \"[title]\");\n\n\t\t\t// If this is an Twitter or App.net account remove @\n\t\t\tif ( service.title == 'Twitter' || service.title == 'App.net' )\n\t\t\t\trow.find(\".podlove-service-value\").data(\"podlove-input-remove\", \"@\");\n\n\t\t\t// If this is an Website, check if the URL is valid\n\t\t\tif ( service.title == 'Website' )\n\t\t\t\trow.find(\".podlove-service-value\").data(\"podlove-input-type\", \"url\");\n\t\t});\n\t}\n\n\t$(document).on('click', '.podlove-service-link',  function() {\n\t\tif( $(this).parent().find(\".podlove-service-value\").val() !== '' )\n\t\t\twindow.open( $(this).data(\"service-url-scheme\").replace( '%account-placeholder%', $(this).parent().find(\".podlove-service-value\").val() ) );\n\t});\t\n\n\t$(document).on('keydown', '.podlove-service-value',  function() {\n\t\t$(this).parent().find(\".podlove-service-link\").show();\n\t});\n\n\t$(document).on('focusout', '.podlove-service-value',  function() {\n\t\tif( $(this).val() == '' )\n\t\t\t$(this).parent().find(\".podlove-service-link\").hide();\n\t});\n\n\t$(document).ready(function() {\n\t\tservice_dropdown_handler();\n\n\t\t$(\".podlove_social_wrapper table\").each(function(e) {\n\n\t\t\tvar $this = $(this);\n\t\t\tvar category = $this.closest(\".podlove_social_wrapper\").data(\"category\");\n\n\t\t\t$this.podloveDataTable({\n\t\t\t\trowTemplate: \"#service-row-template-\" + category,\n\t\t\t\tdeleteHandle: \".service_remove\",\n\t\t\t\tsortableHandle: \".reorder-handle\",\n\t\t\t\taddRowHandle: \"#add_new_service_button-\" + category,\n\t\t\t\tdata: PODLOVE.Social[category].existing_services,\n\t\t\t\tdataPresets: PODLOVE.Social[category].services,\n\t\t\t\tonRowLoad: function(o) {\n\t\t\t\t\tvar i = $this.find(\"tr\").length;\n\n\t\t\t\t\to.row = o.row.replace(/\\{\\{service-id\\}\\}/g, o.object.id);\n\t\t\t\t\to.row = o.row.replace(/\\{\\{id\\}\\}/g, i);\n\t\t\t\t},\n\t\t\t\tonRowAdd: function(o, init) {\n\t\t\t\t\tvar row = $(\".podlove_social_wrapper[data-category='\" + category + \"'] .services_table_body tr:last\");\n\n\t\t\t\t\t// select object in object-dropdown\n\t\t\t\t\trow.find('select.podlove-service-dropdown option[value=\"' + o.object.id + '\"]').attr('selected',true);\n\t\t\t\t\t// set value\n\t\t\t\t\trow.find('input.podlove-service-value').val(o.entry.value);\n\t\t\t\t\t// set title\n\t\t\t\t\trow.find('input.podlove-service-title').val(o.entry.title);\n\t\t\t\t\t// Show account/URL if not empty\n\t\t\t\t\tif( row.find('input.podlove-service-value').val() !== '' )\n\t\t\t\t\t\trow.find('input.podlove-service-value').parent().find(\".podlove-service-link\").show();\n\n\t\t\t\t\t// Update Chosen before we focus on the new service\n\t\t\t\t\tupdate_chosen();\n\t\t\t\t\tvar new_row_id = row.find('select.podlove-service-dropdown').last().attr('id');\t\n\t\t\t\t\t$('select.podlove-service-dropdown').change();\n\t\t\t\t\t\n\t\t\t\t\t// Focus new service\n\t\t\t\t\tif (!init) {\n\t\t\t\t\t\t$(\"#\" + new_row_id + \"_chosen\").find(\"a\").focus();\n\t\t\t\t\t}\n\t\t\t\t\tclean_up_input();\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t});\n}(jQuery));\n"
  },
  {
    "path": "lib/modules/social/model/contributor_service.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Model;\n\nuse Podlove\\Model\\Base;\n\n/**\n * A contributor contributes to a podcast/show.\n */\nclass ContributorService extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function save()\n    {\n        global $wpdb;\n\n        if (!$this->position) {\n            $pos = $wpdb->get_var(\n                sprintf(\n                    'SELECT MAX(position)+1 FROM %s WHERE contributor_id = %d',\n                    self::table_name(),\n                    $this->contributor_id\n                )\n            );\n\n            $this->position = $pos ? $pos : 1;\n        }\n\n        parent::save();\n    }\n\n    public function get_service()\n    {\n        return $this->with_blog_scope(function () {\n            return Service::find_one_by_id($this->service_id);\n        });\n    }\n\n    public function get_service_url()\n    {\n        $service = $this->get_service();\n\n        return str_replace('%account-placeholder%', $this->value, $service->url_scheme);\n    }\n\n    public static function find_by_contributor_id_and_category($contributor_id, $category = 'social')\n    {\n        $contributor_id = (int) $contributor_id;\n        $category = $category == 'social' ? 'social' : 'donation';\n\n        return self::all('WHERE service_id IN (SELECT id FROM '.Service::table_name().\" WHERE `category` = '\".$category.\"' ) AND `contributor_id` = \".$contributor_id);\n    }\n}\n\nContributorService::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nContributorService::property('contributor_id', 'INT');\nContributorService::property('service_id', 'INT');\nContributorService::property('value', 'TEXT');\nContributorService::property('title', 'TEXT');\nContributorService::property('position', 'FLOAT');\n"
  },
  {
    "path": "lib/modules/social/model/service.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Model;\n\nuse Podlove\\Model\\Base;\nuse Podlove\\Model\\Image;\n\nclass Service extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function from_data($data)\n    {\n        $service = new self();\n\n        foreach ($data as $key => $value) {\n            $service->{$key} = $value;\n        }\n\n        return $service;\n    }\n\n    /**\n     * @deprecated since 2.2.0, use ::image() instead\n     */\n    public function get_logo()\n    {\n        return \\Podlove\\PLUGIN_URL.'/lib/modules/social/images/icons/'.$this->logo;\n    }\n\n    public function image()\n    {\n        return new Image(\\Podlove\\PLUGIN_URL.'/lib/modules/social/images/icons/'.$this->logo, $this->title);\n    }\n}\n\nService::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nService::property('category', 'VARCHAR(255)');\nService::property('type', 'VARCHAR(255)');\nService::property('title', 'VARCHAR(255)');\nService::property('description', 'TEXT');\nService::property('logo', 'TEXT');\nService::property('url_scheme', 'TEXT');\n"
  },
  {
    "path": "lib/modules/social/model/show_service.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Model;\n\nuse Podlove\\Model\\Base;\n\n/**\n * A contributor contributes to a podcast/show.\n */\nclass ShowService extends Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public function get_service()\n    {\n        return $this->with_blog_scope(function () {\n            return Service::find_one_by_id($this->service_id);\n        });\n    }\n\n    public function get_service_url()\n    {\n        $service = $this->get_service();\n\n        return str_replace('%account-placeholder%', $this->value, $service->url_scheme);\n    }\n\n    public static function find_by_category($category = 'social')\n    {\n        $category = $category == 'social' ? 'social' : 'donation';\n\n        return self::all('WHERE service_id IN (SELECT id FROM '.Service::table_name().\" WHERE `category` = '\".$category.\"' ) ORDER BY position ASC\");\n    }\n}\n\nShowService::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nShowService::property('service_id', 'INT');\nShowService::property('value', 'TEXT');\nShowService::property('title', 'TEXT');\nShowService::property('position', 'FLOAT');\n"
  },
  {
    "path": "lib/modules/social/repair_social.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social;\n\nuse Podlove\\Modules\\Social\\Model\\ContributorService;\nuse Podlove\\Modules\\Social\\Model\\Service;\nuse Podlove\\Repair;\n\nclass RepairSocial\n{\n    public static function init()\n    {\n        add_action('podlove_repair_do_repair', [__CLASS__, 'fix_duplicate_services']);\n        add_action('podlove_repair_do_repair', [__CLASS__, 'fix_missing_services']);\n        add_filter('podlove_repair_descriptions', [__CLASS__, 'description']);\n    }\n\n    public static function description($descriptions)\n    {\n        return array_merge($descriptions, ['<strong>removes duplicate services</strong> if you have any']);\n    }\n\n    public static function fix_missing_services()\n    {\n        $count_before = Service::count();\n        Social::build_missing_services();\n        $count_after = Service::count();\n\n        if ($count_before < $count_after) {\n            Repair::add_to_repair_log(\n                sprintf(\n                    __('Added %d missing social services', 'podlove-podcasting-plugin-for-wordpress'),\n                    $count_after - $count_before\n                )\n            );\n        }\n    }\n\n    public static function fix_duplicate_services()\n    {\n        global $wpdb;\n\n        $services = self::find_duplicate_services();\n\n        if (!is_array($services) || empty($services)) {\n            Repair::add_to_repair_log(__('Services did not need repair', 'podlove-podcasting-plugin-for-wordpress'));\n\n            return;\n        }\n\n        foreach ($services as $service) {\n            // update contributor services\n            $sql = 'UPDATE '.ContributorService::table_name().' SET service_id = '.$service['id'].' WHERE service_id IN (\n\t\t\t\tSELECT id FROM '.Service::table_name().' WHERE `type` = \"'.$service['type'].'\"\n\t\t\t)';\n            $wpdb->query($sql);\n\n            // update show services\n            $sql = 'UPDATE '.Model\\ShowService::table_name().' SET service_id = '.$service['id'].' WHERE service_id IN (\n\t\t\t\tSELECT id FROM '.Service::table_name().' WHERE `type` = \"'.$service['type'].'\"\n\t\t\t)';\n            $wpdb->query($sql);\n\n            // delete obsolete services\n            $sql = 'DELETE FROM '.Service::table_name().' WHERE id != '.$service['id'].' AND `type` = \"'.$service['type'].'\"';\n            $wpdb->query($sql);\n        }\n\n        Repair::add_to_repair_log(\n            sprintf(\n                __('Consolidated duplicate services (%s)', 'podlove-podcasting-plugin-for-wordpress'),\n                implode(', ', array_map(function ($s) {\n                    return $s['type'];\n                }, $services))\n            )\n        );\n    }\n\n    private static function find_duplicate_services()\n    {\n        global $wpdb;\n\n        $sql = 'SELECT id, `type`, COUNT(*) cnt FROM '.Service::table_name().' GROUP BY `type`, `category` HAVING cnt > 1';\n\n        return $wpdb->get_results($sql, ARRAY_A);\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social;\n\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Social\\Model\\ContributorService;\nuse Podlove\\Modules\\Social\\Model\\Service;\nuse Podlove\\Modules\\Social\\Model\\ShowService;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v1';\n    public const api_base = 'social';\n\n    // todo: delete\n    // todo: create\n    // todo: update\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base.'/services', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_services'],\n                'args' => [\n                    'category' => [\n                        'description' => __('category: social, donation, internal'),\n                        'type' => 'string',\n                    ],\n                ],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/services/contributor/(?P<id>[\\d]+)', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contributor_services'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor id'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => '__return_true',\n            ],\n        ]);\n    }\n\n    public function get_services($request)\n    {\n        $category = $request->get_param('category');\n        $services = Service::all();\n        $result = [];\n\n        foreach ($services as $service) {\n            if (isset($category) == false || $category == $service->category) {\n                $item = $service->to_array();\n                $item['logo_url'] = $service->image()->url();\n\n                array_push($result, $item);\n            }\n        }\n\n        return new \\WP_REST_Response($result);\n    }\n\n    public function get_contributor_services($request)\n    {\n        $contributor = $request->get_param('id');\n        $category = $request->get_param('category');\n        $services = ContributorService::find_by_contributor_id_and_category($contributor, $category);\n\n        $entries = array_map(function ($entry) {\n            return $entry->to_array();\n        }, $services);\n\n        return new \\WP_REST_Response($entries);\n    }\n}\n\nclass WP_REST_PodloveContributorService_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'social';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, $this->rest_base.'/services', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'args' => [\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                ],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, $this->rest_base.'/services/(?P<id>[\\d]+)', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, $this->rest_base.'/contributors/(?P<id>[\\d]+)', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contributor_services'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_contributor_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, $this->rest_base.'/contributors/service/(?P<id>[\\d]+)', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_contributor_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_contributor_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_contributor_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('contributor social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, $this->rest_base.'/podcast', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_podcast_services'],\n                'args' => [\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_podcast_service'],\n                'args' => [\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n        ]);\n\n        register_rest_route($this->namespace, $this->rest_base.'/podcast/service/(?P<id>[\\d]+)', [\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_podcast_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('podcast social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_podcast_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('podcast social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_podcast_service'],\n                'args' => [\n                    'id' => [\n                        'description' => __('podcast social service id', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'category' => [\n                        'description' => __('category: social, donation, internal', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ],\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        return true;\n    }\n\n    public function get_items($request)\n    {\n        $category = $request->get_param('category');\n        $services = Service::all();\n        $result = [];\n\n        foreach ($services as $service) {\n            if (isset($category) == false || $category == $service->category) {\n                $item = $service->to_array();\n                $item['logo_url'] = $service->image()->url();\n\n                array_push($result, $item);\n            }\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse($result);\n    }\n\n    public function get_contributor_services($request)\n    {\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n        if (!$contributor) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $category = $request->get_param('category');\n        $services = ContributorService::find_by_contributor_id_and_category($id, $category);\n\n        $result = [];\n        foreach ($services as $service) {\n            $item = $service->to_array();\n            $val['id'] = $service->id;\n            $val['contributor_id'] = $item['contributor_id'];\n            $val['service_id'] = $item['service_id'];\n            $val['account_url'] = $service->get_service_url();\n            $val['title'] = $item['title'];\n            $val['position'] = $item['position'];\n            array_push($result, $val);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse($result);\n    }\n\n    public function get_podcast_services($request)\n    {\n        $category = $request->get_param('category');\n        $services = ShowService::find_by_category($category);\n\n        $result = [];\n        foreach ($services as $service) {\n            $item = $service->to_array();\n            $val['id'] = $service->id;\n            $val['service_id'] = $item['service_id'];\n            $val['account_url'] = $service->get_service_url();\n            $val['title'] = $item['title'];\n            $val['position'] = $item['position'];\n            array_push($result, $val);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse($result);\n    }\n\n    public function get_item($request)\n    {\n        $id = $request->get_param('id');\n        $service = Service::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $result = [\n            'category' => $service->category,\n            'title' => $service->title,\n            'description' => $service->description,\n            'logo' => $service->logo,\n            'url_scheme' => $service->url_scheme,\n            'logo_url' => $service->image()->url()\n        ];\n\n        return new \\Podlove\\Api\\Response\\OkResponse($result);\n    }\n\n    public function get_contributor_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ContributorService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $data = [\n            'contributor_id' => $service->contributor_id,\n            'service_id' => $service->service_id,\n            'account_url' => $service->get_service_url(),\n            'title' => $service->title,\n            'position' => $service->position\n        ];\n\n        return new \\Podlove\\Api\\Response\\OkResponse($data);\n    }\n\n    public function get_podcast_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ShowService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $data = [\n            'service_id' => $service->service_id,\n            'account_url' => $service->get_service_url(),\n            'title' => $service->title,\n            'position' => $service->position\n        ];\n\n        return new \\Podlove\\Api\\Response\\OkResponse($data);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function create_contributor_service($request)\n    {\n        $id = $request->get_param('id');\n        $contributor = Contributor::find_by_id($id);\n        if (!$contributor) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $service = new ContributorService();\n        $service->contributor_id = $id;\n        $service->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $service->id\n        ]);\n    }\n\n    public function create_podcast_service($request)\n    {\n        $service = new ShowService();\n        $service->save();\n\n        return new \\Podlove\\Api\\Response\\CreateResponse([\n            'status' => 'ok',\n            'id' => $service->id\n        ]);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_contributor_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ContributorService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['contributor_id'])) {\n            $cid = $request['contributor_id'];\n            $service->contributor_id = $cid;\n        }\n\n        if (isset($request['service_id'])) {\n            $sid = $request['service_id'];\n            $service->service_id = $sid;\n        }\n\n        if (isset($request['account'])) {\n            $val = $request['account'];\n            $service->value = $val;\n        }\n\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $service->title = $title;\n        }\n\n        if (isset($request['position'])) {\n            $pos = $request['position'];\n            $service->position = $pos;\n        }\n\n        $service->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_podcast_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ShowService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        if (isset($request['service_id'])) {\n            $sid = $request['service_id'];\n            $service->service_id = $sid;\n        }\n\n        if (isset($request['account'])) {\n            $val = $request['account'];\n            $service->value = $val;\n        }\n\n        if (isset($request['title'])) {\n            $title = $request['title'];\n            $service->title = $title;\n        }\n\n        if (isset($request['position'])) {\n            $pos = $request['position'];\n            $service->position = $pos;\n        }\n\n        $service->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_contributor_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ContributorService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $service->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_podcast_service($request)\n    {\n        $id = $request->get_param('id');\n        $service = ShowService::find_by_id($id);\n\n        if (!$service) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $service->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/settings/podcast_settings_donation_tab.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Settings;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass PodcastSettingsDonationTab extends Tab\n{\n    private static $nonce = 'update_podcast_settings_donations';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = ['donations'];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_podcast'][$key];\n        }\n        update_option('podlove_podcast', $settings);\n\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'is_table' => false,\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php echo sprintf(\n\t\t\t    __('These are the possibilities to donate for your Podcast. Display this list using the shortcode %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t    '<code>[podlove-podcast-donations-list]</code>'\n\t\t\t); ?>\n\t\t</p>\n\t\t<?php\n\n\t\t\t    \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n\t\t\t        $wrapper = new \\Podlove\\Form\\Input\\DivWrapper($form);\n\n\t\t\t        $wrapper->callback('services', [\n\t\t\t            'callback' => [__CLASS__, 'podcast_form_extension_form'],\n\t\t\t        ]);\n\t\t\t    });\n    }\n\n    public static function podcast_form_extension_form()\n    {\n        $services = \\Podlove\\Modules\\Social\\Model\\ShowService::find_by_category('donation');\n        \\Podlove\\Modules\\Social\\Social::services_form_table($services, 'podlove_podcast[donations]', 'donation');\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/settings/podcast_settings_social_tab.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Settings;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass PodcastSettingsSocialTab extends Tab\n{\n    private static $nonce = 'update_podcast_services';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = ['services'];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_podcast'][$key];\n        }\n        update_option('podlove_podcast', $settings);\n\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'is_table' => false,\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php echo sprintf(\n\t\t\t    __('These are the current social media acccount of your podcast. Display this list using the shortcode %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t    '<code>[podlove-podcast-social-media-list]</code>'\n\t\t\t); ?>\n\t\t</p>\n\t\t<?php\n\t\t\t\\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n\t\t\t    $wrapper = new \\Podlove\\Form\\Input\\DivWrapper($form);\n\n\t\t\t    $wrapper->callback('services', [\n\t\t\t        'callback' => [__CLASS__, 'podcast_form_extension_form'],\n\t\t\t    ]);\n\t\t\t});\n    }\n\n    public static function podcast_form_extension_form()\n    {\n        $services = \\Podlove\\Modules\\Social\\Model\\ShowService::find_by_category();\n        \\Podlove\\Modules\\Social\\Social::services_form_table($services, 'podlove_podcast[services]');\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/shortcodes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social;\n\nclass Shortcodes\n{\n    public static function init()\n    {\n        add_shortcode('podlove-podcast-social-media-list', [__CLASS__, 'social_media_list']);\n        add_shortcode('podlove-podcast-donations-list', [__CLASS__, 'podcast_donations_list']);\n    }\n\n    /**\n     * [podlove-podcast-social-media-list] shortcode.\n     */\n    public static function social_media_list()\n    {\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@social/podcast-social-media-list.twig');\n    }\n\n    /**\n     * [podlove-podcast-donations-list] shortcode.\n     */\n    public static function podcast_donations_list()\n    {\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@social/podcast-donations-list.twig');\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/social.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social;\n\nuse Podlove\\Modules\\Social\\Model\\ContributorService;\nuse Podlove\\Modules\\Social\\Model\\Service;\nuse Podlove\\Modules\\Social\\Model\\ShowService;\nuse Symfony\\Component\\Yaml\\Yaml;\n\nclass Social extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Social & Donations';\n    protected $module_description = 'Manage social media accounts and donations.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('podlove_module_was_activated_social', [$this, 'was_activated']);\n        add_action('podlove_podcast_settings_tabs', [$this, 'podcast_settings_social_tab']);\n        add_action('podlove_podcast_settings_tabs', [$this, 'podcast_settings_donation_tab']);\n\n        add_action('update_option_podlove_podcast', [$this, 'save_social_setting'], 10, 2);\n        add_action('update_option_podlove_podcast', [$this, 'save_donation_setting'], 10, 2);\n        add_action('rest_api_init', [$this, 'api_init']);\n        add_action('podlove_update_entity_contributor', [$this, 'save_contributor'], 10, 2);\n        add_action('podlove_create_entity_contributor', [$this, 'save_contributor'], 10, 2);\n\n        add_filter('podlove_contributor_settings_sections', [$this, 'register_contributor_sections'], 10, 2);\n\n        add_action('admin_print_styles', [$this, 'admin_print_styles']);\n\n        add_filter('podlove_contributor_list_table_columns', [$this, 'add_new_contributor_column']);\n\n        add_action('podlove_xml_export', [$this, 'expandExportFile']);\n        add_action('podlove_import_jobs', [$this, 'expandImport']);\n\n        add_filter('podlove_twig_file_loader', function ($file_loader) {\n            $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'social', 'templates']), 'social');\n\n            return $file_loader;\n        });\n\n        \\Podlove\\Modules\\Contributors\\Template\\Contributor::add_accessor(\n            'services',\n            ['\\Podlove\\Modules\\Social\\TemplateExtensions', 'accessorContributorServices'],\n            5\n        );\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'services',\n            ['\\Podlove\\Modules\\Social\\TemplateExtensions', 'accessorPodcastServices'],\n            4\n        );\n\n        add_filter('podlove_cache_tainting_classes', [$this, 'cache_tainting_classes']);\n\n        RepairSocial::init();\n        Shortcodes::init();\n    }\n\n    public function cache_tainting_classes($classes)\n    {\n        return array_merge($classes, [\n            Service::name(),\n            ShowService::name(),\n            ContributorService::name(),\n        ]);\n    }\n\n    public function was_activated($module_name)\n    {\n        Service::build();\n        ShowService::build();\n        ContributorService::build();\n\n        self::build_missing_services();\n    }\n\n    public function uninstall()\n    {\n        Service::destroy();\n        ShowService::destroy();\n        ContributorService::destroy();\n    }\n\n    public static function services_config()\n    {\n        $file = implode(\n            DIRECTORY_SEPARATOR,\n            [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'social', 'data', 'services.yml']\n        );\n\n        return Yaml::parse(file_get_contents($file));\n    }\n\n    public static function update_existing_services()\n    {\n        foreach (self::services_config() as $service_key => $service) {\n            $s = Service::find_one_by_where(\n                sprintf('`category` = \"%s\" AND `type` = \"%s\"', esc_sql($service['category']), esc_sql($service['name']))\n            );\n\n            if ($s) {\n                $s->title = $service['title'];\n                $s->description = $service['description'];\n                $s->logo = $service['logo'];\n                $s->url_scheme = $service['url_scheme'];\n                $s->save();\n            }\n        }\n    }\n\n    public static function build_missing_services()\n    {\n        foreach (self::services_config() as $service_key => $service) {\n            $service_exists = (bool) Service::find_one_by_where(\n                sprintf('`category` = \"%s\" AND `type` = \"%s\"', $service['category'], $service['name'])\n            );\n\n            if (!$service_exists) {\n                $s = new Service();\n                $s->title = $service['title'];\n                $s->category = $service['category'];\n                $s->type = $service['name'];\n                $s->description = $service['description'];\n                $s->logo = $service['logo'];\n                $s->url_scheme = $service['url_scheme'];\n                $s->save();\n            }\n        }\n    }\n\n    public function save_contributor($contributor)\n    {\n        if (!isset($_POST['podlove_contributor'])) {\n            return;\n        }\n\n        if (!isset($_POST['podlove_contributor']['services']) && !isset($_POST['podlove_contributor']['donations'])) {\n            return;\n        }\n\n        $delete_service = function ($type) use ($contributor) {\n            foreach (\\Podlove\\Modules\\Social\\Model\\ContributorService::all('WHERE `contributor_id` = '.$contributor->id) as $ContributorService) {\n                $service = \\Podlove\\Modules\\Social\\Model\\Service::find_by_id($ContributorService->service_id);\n                if ($service->category == $type) {\n                    $ContributorService->delete();\n                }\n            }\n        };\n\n        foreach (['donations', 'services'] as $type) {\n            $position = 0;\n\n            if (isset($_POST['podlove_contributor'][$type])) {\n                $delete_service($type == 'donations' ? 'donation' : 'social');\n                foreach ($_POST['podlove_contributor'][$type] as $service_appearance) {\n                    foreach ($service_appearance as $service_id => $service) {\n                        $c = new \\Podlove\\Modules\\Social\\Model\\ContributorService();\n                        $c->position = $position;\n                        $c->contributor_id = $contributor->id;\n                        $c->service_id = $service_id;\n                        $c->value = $service['value'];\n                        $c->title = $service['title'];\n                        $c->save();\n                    }\n                    ++$position;\n                }\n            }\n        }\n    }\n\n    public function save_service_setting($old, $new, $form_key = 'services', $type = 'social')\n    {\n        foreach (\\Podlove\\Modules\\Social\\Model\\ShowService::find_by_category($type) as $service) {\n            $service->delete();\n        }\n\n        if (!isset($new[$form_key])) {\n            return;\n        }\n\n        $services_appearances = $new[$form_key];\n\n        $position = 0;\n        foreach ($services_appearances as $service_appearance) {\n            foreach ($service_appearance as $service_id => $service) {\n                $c = new \\Podlove\\Modules\\Social\\Model\\ShowService();\n                $c->position = $position;\n                $c->service_id = $service_id;\n                $c->value = $service['value'];\n                $c->title = $service['title'];\n                $c->save();\n            }\n            ++$position;\n        }\n    }\n\n    public function save_social_setting($old, $new)\n    {\n        $this->save_service_setting($old, $new);\n    }\n\n    public function save_donation_setting($old, $new)\n    {\n        $this->save_service_setting($old, $new, 'donations', 'donation');\n    }\n\n    public function podcast_settings_social_tab($tabs)\n    {\n        $tabs->addTab(new Settings\\PodcastSettingsSocialTab('social', __('Social', 'podlove-podcasting-plugin-for-wordpress')));\n\n        return $tabs;\n    }\n\n    public function podcast_settings_donation_tab($tabs)\n    {\n        $tabs->addTab(new Settings\\PodcastSettingsDonationTab('donations', __('Donations', 'podlove-podcasting-plugin-for-wordpress')));\n\n        return $tabs;\n    }\n\n    public function add_new_contributor_column($columns)\n    {\n        $keys = array_keys($columns);\n        $insertIndex = array_search('gender', $keys) + 1; // after author column\n\n        // insert contributors at that index\n        return array_slice($columns, 0, $insertIndex, true)\n                   + [\n                       'social' => __('Social', 'podlove-podcasting-plugin-for-wordpress'),\n                       'donation' => __('Donation', 'podlove-podcasting-plugin-for-wordpress'),\n                   ]\n                   + array_slice($columns, $insertIndex, count($columns) - 1, true);\n    }\n\n    public function register_contributor_sections($sections)\n    {\n        $sections['social'] = [\n            'title' => __('Social', 'podlove-podcasting-plugin-for-wordpress'),\n            'fields' => [\n                'services_form_table' => [\n                    'field_type' => 'callback',\n                    'field_options' => [\n                        'nolabel' => true,\n                        'callback' => function () {\n                            if (isset($_GET['contributor'])) {\n                                $services = \\Podlove\\Modules\\Social\\Model\\ContributorService::find_by_contributor_id_and_category($_GET['contributor'], 'social');\n                            } else {\n                                $services = [];\n                            }\n\n                            \\Podlove\\Modules\\Social\\Social::services_form_table($services, 'podlove_contributor[services]', 'social');\n                        },\n                    ],\n                ],\n            ],\n        ];\n\n        $sections['donation'] = [\n            'title' => __('Donation', 'podlove-podcasting-plugin-for-wordpress'),\n            'fields' => [\n                'services_form_table' => [\n                    'field_type' => 'callback',\n                    'field_options' => [\n                        'nolabel' => true,\n                        'callback' => function () {\n                            if (isset($_GET['contributor'])) {\n                                $services = \\Podlove\\Modules\\Social\\Model\\ContributorService::find_by_contributor_id_and_category($_GET['contributor'], 'donation');\n                            } else {\n                                $services = [];\n                            }\n\n                            \\Podlove\\Modules\\Social\\Social::services_form_table($services, 'podlove_contributor[donations]', 'donation');\n                        },\n                    ],\n                ],\n            ],\n        ];\n\n        return $sections;\n    }\n\n    public static function services_form_table($current_services = [], $form_base_name = 'podlove_contributor[services]', $category = 'social')\n    {\n        $cjson = [];\n        $converted_services = [];\n        $wrapper_id = \"services-form-{$category}\";\n\n        foreach (\\Podlove\\Modules\\Social\\Model\\Service::find_all_by_property('category', $category) as $service) {\n            $cjson[$service->id] = [\n                'id' => $service->id,\n                'title' => $service->title,\n                'description' => $service->description,\n                'url_scheme' => $service->url_scheme,\n            ];\n        }\n\n        foreach ($current_services as $current_service_key => $service) {\n            $converted_services[$service->id] = [\n                'id' => $service->service_id,\n                'value' => $service->value,\n                'title' => $service->title,\n            ];\n        } ?>\n\t\t<div id=\"<?php echo $wrapper_id; ?>\" class=\"podlove_social_wrapper\" data-category=\"<?php echo $category; ?>\">\n\t\t\t<table class=\"podlove_alternating\" border=\"0\" cellspacing=\"0\">\n\t\t\t\t<thead>\n\t\t\t\t\t<tr>\n\n\t\t\t\t\t\t<th><?php _e('Service', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th><?php _e('Account/URL', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th style=\"width: 60px\"><?php _e('Remove', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t<th style=\"width: 30px\"></th>\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody class=\"services_table_body\" style=\"min-height: 50px;\">\n\t\t\t\t\t<tr class=\"services_table_body_placeholder\" style=\"display: none;\">\n\t\t\t\t\t\t<td><em><?php _e('No Services were added yet.', 'podlove-podcasting-plugin-for-wordpress'); ?></em></td>\n\t\t\t\t\t</tr>\n\t\t\t\t</tbody>\n\t\t\t</table>\n\n\t\t\t<div id=\"add_new_contributor_wrapper\">\n\t\t\t\t<input class=\"button\" id=\"add_new_service_button-<?php echo $category; ?>\" value=\"+\" type=\"button\" />\n\t\t\t</div>\n\n\t\t\t<script type=\"text/template\" id=\"service-row-template-<?php echo $category; ?>\">\n\t\t\t<tr class=\"media_file_row podlove-service-table\" data-service-id=\"{{service-id}}\">\n\n\t\t\t\t<td class=\"podlove-service-column\">\n\t\t\t\t\t<select name=\"<?php echo $form_base_name; ?>[{{id}}][{{service-id}}][id]\" class=\"chosen-image podlove-service-dropdown\">\n\t\t\t\t\t\t<option value=\"\"><?php echo __('Choose Service', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t<?php foreach (\\Podlove\\Modules\\Social\\Model\\Service::all('WHERE `category` = \\''.$category.'\\' ORDER BY `title`') as $service) { ?>\n\t\t\t\t\t\t\t<option value=\"<?php echo $service->id; ?>\" data-img-src=\"<?php echo $service->image()->setWidth(45)->url(); ?>\"><?php echo $service->title; ?></option>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t</select>\n\t\t\t\t</td>\n\t\t\t\t<td>\n\t\t\t\t\t<input type=\"text\" name=\"<?php echo $form_base_name; ?>[{{id}}][{{service-id}}][value]\" id=\"podlove_contributor_services_{{id}}_{{service-id}}_value\" class=\"podlove-service-value podlove-check-input\" /><span class=\"podlove-input-status\" data-podlove-input-status-for=\"podlove_contributor_services_{{id}}_{{service-id}}_value\"></span>\n\t\t\t\t\t<i class=\"podlove-icon-share podlove-service-link\"></i>\n\t\t\t\t</td>\n\t\t\t\t<td>\n\t\t\t\t\t<input type=\"text\" name=\"<?php echo $form_base_name; ?>[{{id}}][{{service-id}}][title]\" class=\"podlove-service-title\" />\n\t\t\t\t</td>\n\t\t\t\t<td>\n\t\t\t\t\t<span class=\"service_remove\">\n\t\t\t\t\t\t<i class=\"clickable podlove-icon-remove\"></i>\n\t\t\t\t\t</span>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"move column-move\"><i class=\"reorder-handle podlove-icon-reorder\"></i></td>\n\t\t\t</tr>\n\t\t\t</script>\n\n\t\t\t<script type=\"text/javascript\">\n\n\t\t\t\tvar PODLOVE = PODLOVE || {};\n\t\t\t\tPODLOVE.Social = PODLOVE.Social || {};\n\t\t\t\tPODLOVE.Social.<?php echo $category; ?> = {\n\t\t\t\t\texisting_services: <?php echo wp_json_encode($converted_services); ?>,\n\t\t\t\t\tservices: <?php echo wp_json_encode(array_values($cjson)); ?>,\n\t\t\t\t\tform_base_name: \"<?php echo $form_base_name; ?>\"\n\t\t\t\t};\n\n\t\t\t</script>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function admin_print_styles()\n    {\n        if (!isset($_REQUEST['page'])) {\n            return;\n        }\n\n        if (!in_array($_REQUEST['page'], ['podlove_contributor_settings', 'podlove_settings_podcast_handle'])) {\n            return;\n        }\n\n        wp_register_style(\n            'podlove_social_admin_style',\n            $this->get_module_url().'/admin.css',\n            false,\n            \\Podlove\\get_plugin_header('Version')\n        );\n        wp_enqueue_style('podlove_social_admin_style');\n\n        wp_register_script(\n            'podlove_social_admin_script',\n            $this->get_module_url().'/js/admin.js',\n            ['jquery'],\n            \\Podlove\\get_plugin_header('Version')\n        );\n        wp_enqueue_script('podlove_social_admin_script');\n    }\n\n    public function expandExportFile(\\SimpleXMLElement $xml)\n    {\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'services', 'service', '\\Podlove\\Modules\\Social\\Model\\Service');\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'contributorServices', 'contributorService', '\\Podlove\\Modules\\Social\\Model\\ContributorService');\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'showServices', 'showService', '\\Podlove\\Modules\\Social\\Model\\ShowService');\n    }\n\n    public function expandImport($jobs)\n    {\n        $jobs[] = '\\Podlove\\Modules\\Social\\Jobs\\PodcastImportServicesJob';\n        $jobs[] = '\\Podlove\\Modules\\Social\\Jobs\\PodcastImportContributorServicesJob';\n        $jobs[] = '\\Podlove\\Modules\\Social\\Jobs\\PodcastImportShowServicesJob';\n\n        return $jobs;\n    }\n\n    public function api_init()\n    {\n        $api_v1 = new REST_API();\n        $api_v1->register_routes();\n        $api_v2 = new WP_REST_PodloveContributorService_Controller();\n        $api_v2->register_routes();\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/template/service.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social\\Template;\n\nuse Podlove\\Template\\Image;\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Service Template Wrapper.\n *\n * Requires the \"Social\" module.\n *\n * @templatetag service\n */\nclass Service extends Wrapper\n{\n    /**\n     * @var \\Podlove\\Modules\\Social\\Model\\ContributorService\n     */\n    private $contributor_service;\n\n    /**\n     * @var \\Podlove\\Modules\\Social\\Model\\Service\n     */\n    private $service;\n\n    public function __construct($contributor_service, $service = null)\n    {\n        $this->contributor_service = $contributor_service;\n        $this->service = $service;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Service title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        if ($this->contributor_service && $this->contributor_service->title) {\n            return $this->contributor_service->title;\n        }\n\n        return $this->service->title;\n    }\n\n    /**\n     * Service title.\n     *\n     * Deprecated. Use `service.title` instead.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->title();\n    }\n\n    /**\n     * Service type.\n     *\n     * @accessor\n     */\n    public function type()\n    {\n        return $this->service->type;\n    }\n\n    /**\n     * Service description.\n     *\n     * @accessor\n     */\n    public function description()\n    {\n        return $this->service->description;\n    }\n\n    /**\n     * Service profile URL.\n     *\n     * @accessor\n     */\n    public function profileUrl()\n    {\n        return $this->contributor_service->get_service_url();\n    }\n\n    /**\n     * Service value.\n     *\n     * Normally, you want to access the generates url via `profileUrl()`.\n     * But in case you need the raw user value, use this method.\n     *\n     * @accessor\n     */\n    public function rawValue()\n    {\n        return $this->contributor_service->value;\n    }\n\n    /**\n     * Logo URL.\n     *\n     * @deprecated since 2.2.0, use ::image instead\n     */\n    public function logoUrl()\n    {\n        return $this->service->get_logo();\n    }\n\n    /**\n     * Image.\n     *\n     * @see  image\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return new Image($this->service->image());\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->contributor_service, $this->service];\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Social;\n\nuse Podlove\\Modules\\Social\\Model\\ContributorService;\nuse Podlove\\Modules\\Social\\Model\\ShowService;\n\nclass TemplateExtensions\n{\n    /**\n     * List of service profiles.\n     *\n     * Parameters:\n     *\n     * - **category:** (optional) \"social\", \"donation\" or \"all\". Default: \"all\"\n     * - **type:**     (optional) Filter services by type. List of all service types: 500px, about.me, amazon wishlist, app.net, auphonic credits, bandcamp, bitbucket, bitcoin, deviantart, diaspora, dogecoin, dribbble, email, facebook, flickr, foursquare, generic wishlist, github, gittip, google+, instagram, jabber, last.fm, linkedin, litecoin, openstreetmap, orcid, patreon, paypal, miiverse, pinboard, pinterest, playstation network, researchgate, scous, skype, soundcloud, soup, steam, steam wishlist, thomann wishlist, tumblr, twitch, twitter, vimeo, website, xbox live, xing, youtube\n     *\n     * Example:\n     *\n     * ```html\n     * {% for service in contributor.services({category: \"social\"}) %}\n     *   <a target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n     *\t\t{{ service.image.html({width: 20}) }}\n     *   </a>\n     * {% endfor %}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor contributor.services\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $contributor\n     * @param mixed $contribution\n     * @param mixed $args\n     */\n    public static function accessorContributorServices($return, $method_name, $contributor, $contribution, $args = [])\n    {\n        return $contributor->with_blog_scope(function () use ($contributor, $args) {\n            $category = (isset($args['category']) && in_array($args['category'], ['social', 'donation', 'all'])) ? $args['category'] : 'all';\n\n            if ($category == 'all') {\n                $services = ContributorService::find_all_by_contributor_id($contributor->id);\n            } else {\n                $services = ContributorService::find_by_contributor_id_and_category($contributor->id, $category);\n            }\n\n            $services = array_filter($services, function ($s) {\n                return $s->service_id > 0;\n            });\n\n            if (isset($args['type']) && $args['type']) {\n                $services = array_filter($services, function ($s) use ($args) {\n                    return $s->get_service()->type == $args['type'];\n                });\n            }\n\n            usort($services, function ($a, $b) {\n                if ($a == $b) {\n                    return 0;\n                }\n\n                return $a->position < $b->position ? -1 : 1;\n            });\n\n            return array_map(function ($service) {\n                return new Template\\Service($service, $service->get_service());\n            }, $services);\n        });\n    }\n\n    /**\n     * List of service profiles.\n     *\n     * Parameters:\n     *\n     * - **category:** (optional) \"social\", \"donation\" or \"all\". Default: \"all\"\n     * - **type:**     (optional) Filter services by type. List of all service types: 500px, amazon wishlist, app.net, bandcamp, bitbucket, bitcoin, deviantart, diaspora, dogecoin, dribbble, facebook, flickr, generic wishlist, github, google+, instagram, jabber, last.fm, linkedin, litecoin, openstreetmap, paypal, miiverse, pinboard, pinterest, playstation network, skype, soundcloud, soup, steam, steam wishlist, thomann wishlist, twitch, tumblr, twitter, website, xbox live, xing, youtube\n     *\n     * Example:\n     *\n     * ```html\n     * {% for service in podcast.services({category: \"social\"}) %}\n     *   <a target=\"_blank\" title=\"{{ service.title }}\" href=\"{{ service.profileUrl }}\">\n     *\t\t{{ service.image.html({width: 20}) }}\n     *   </a>\n     * {% endfor %}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.services\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $podcast\n     * @param mixed $args\n     */\n    public static function accessorPodcastServices($return, $method_name, $podcast, $args = [])\n    {\n        return $podcast->with_blog_scope(function () use ($args) {\n            $category = isset($args['category']) && in_array($args['category'], ['social', 'donation', 'all']) ? $args['category'] : 'all';\n\n            if ($category == 'all') {\n                $services = ShowService::all('ORDER BY position ASC');\n            } else {\n                $services = ShowService::find_by_category($category);\n            }\n\n            if (isset($args['type']) && $args['type']) {\n                $services = array_filter($services, function ($s) use ($args) {\n                    return $s->get_service()->type == $args['type'];\n                });\n            }\n\n            return array_map(function ($service) {\n                return new Template\\Service($service, $service->get_service());\n            }, $services);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/social/templates/podcast-donations-list.twig",
    "content": "<ul class=\"podcast_services\">\n{% for service in podcast.services({category: \"donation\"}) %}\n\t<li>\n    \t<a href=\"{{ service.profileUrl }}\" title=\"{{ service.description }}\">\n\t\t\t{{\n\t\t\t\tservice.image.html({\n\t\t\t\t\twidth: 16,\n\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t}) \n\t\t\t}} {{ service.title }}\n        </a>\n    </li>\n{% endfor %}\n</ul>\n\n<style>\n.podcast_services li {\n\tlist-style: none;\n}\n</style>"
  },
  {
    "path": "lib/modules/social/templates/podcast-social-media-list.twig",
    "content": "<ul class=\"podcast_services\">\n{% for service in podcast.services({category: \"social\"}) %}\n\t<li>\n    \t<a href=\"{{ service.profileUrl }}\" title=\"{{ service.description }}\">\n\t\t\t{{\n\t\t\t\tservice.image.html({\n\t\t\t\t\twidth: 16,\n\t\t\t\t\talt: service.title ~ \" Icon\"\n\t\t\t\t}) \n\t\t\t}} {{ service.title }}\n        </a>\n    </li>\n{% endfor %}\n</ul>\n\n<style>\n.podcast_services li {\n\tlist-style: none;\n}\n</style>"
  },
  {
    "path": "lib/modules/soundbite/soundbite.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Soundbite;\n\nclass Soundbite extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Soundbite';\n    protected $module_description = 'Points to a soundbite within a podcast episode. The intended use includes episodes previews, discoverability, audiogram generation, episode highlights, etc. (adds podcast::soundbite tag to RSS feed)';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_filter('podlove_episode_form_data', [$this, 'extend_epsiode_form'], 10, 2);\n\n        $this->add_soundbite_to_feed();\n    }\n\n    public function extend_epsiode_form($form_data, $episode)\n    {\n        $form_data[] = [\n            'type' => 'callback',\n            'key' => 'soundbite',\n            'options' => ['callback' => [$this, 'soundbite_form']],\n            'position' => 470,\n        ];\n\n        return $form_data;\n    }\n\n    public function soundbite_form()\n    {\n        ?>\n            <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n                <podlove-soundbite></podlove-soundbite>\n            </div>\n        <?php\n    }\n\n    public function add_soundbite_to_feed()\n    {\n        add_action('podlove_append_to_feed_entry', [$this, 'add_soundbite_to_episode_feed'], 10, 4);\n    }\n\n    public function add_soundbite_to_episode_feed($podcast, $episode, $feed, $format)\n    {\n        if ($episode->get_soundbite_start() && $episode->get_soundbite_duration()) {\n            $title = $episode->soundbite_title;\n            $start = $episode->soundbite_start;\n            $duration = $episode->soundbite_duration;\n\n            $start_sec = \\Podlove\\NormalPlayTime\\Parser::parse($start, 'ms');\n            $start_sec = $start_sec / 1000.;\n            $duration_sec = \\Podlove\\NormalPlayTime\\Parser::parse($duration, 'ms');\n            $duration_sec = $duration_sec / 1000.;\n\n            if ($duration_sec > 0) {\n                $doc = new \\DOMDocument();\n                $node = $doc->createElement('podcast:soundbite');\n                if ($title && strlen($title) > 0) {\n                    $text = $doc->createTextNode($title);\n                    $node->appendChild($text);\n                } else {\n                    $text = $doc->createTextNode('');\n                    $node->appendChild($text);\n                }\n                $attr = $doc->createAttribute('startTime');\n                $attr->value = number_format($start_sec, 3);\n                $node->appendChild($attr);\n                $attr = $doc->createAttribute('duration');\n                $attr->value = number_format($duration_sec, 3);\n                $node->appendChild($attr);\n\n                $xml = $doc->saveXML($node);\n\n                echo \"\\n\\t\\t\".$xml.\"\\n\";\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/subscribe_button/button.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SubscribeButton;\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Model\\Feed;\n\n/**\n * Podlove Subscribe Button.\n *\n * Usage:\n *\n *   $data = [\n *     'title'       => $podcast->title,\n *     'subtitle'    => $podcast->subtitle,\n *     'description' => $podcast->summary,\n *     'cover'       => $podcast->cover_art()->setWidth(400)->url(),\n *     'feeds'       => Button::feeds($podcast->feeds(['only_discoverable' => true])),\n *   ];\n *\n *   if ($podcast->language) {\n *     $args['language'] = Button::language($podcast->language);\n *   }\n *\n *   return (new Button())->render($data, ['size' => 'medium', 'language' => 'de']);\n */\nclass Button\n{\n    private $defaults = [\n        'size' => 'big',\n        'format' => 'cover',\n        'width' => '',\n        'style' => 'filled',\n        'language' => 'en',\n        'color' => '#75ad91',\n        'buttonid' => null,\n        'hide' => false,\n    ];\n\n    private $args = [];\n\n    public function render($data, $args = [])\n    {\n        $this->args = wp_parse_args($args, $this->defaults);\n\n        // whitelist size parameter\n        if (!in_array($this->args['size'], array_keys(Subscribe_Button::sizes()))) {\n            $this->args['size'] = $this->defaults['size'];\n        }\n\n        // whitelist style parameter\n        if (!in_array($this->args['style'], array_keys(Subscribe_Button::styles()))) {\n            $this->args['style'] = $this->defaults['style'];\n        }\n\n        // whitelist format parameter\n        if (!in_array($this->args['format'], array_keys(Subscribe_Button::formats()))) {\n            $this->args['format'] = $this->defaults['format'];\n        }\n\n        $this->args['data'] = $data;\n\n        // allow args to override data\n        $fields = ['title', 'subtitle', 'description', 'cover'];\n        foreach ($fields as $field) {\n            if (isset($this->args[$field]) && $this->args[$field]) {\n                $this->args['data'][$field] = $this->args[$field];\n            }\n        }\n\n        return $this->html();\n    }\n\n    public static function get_random_string()\n    {\n        if (function_exists('openssl_random_pseudo_bytes')) {\n            return bin2hex(openssl_random_pseudo_bytes(7));\n        }\n\n        return dechex(wp_rand());\n    }\n\n    /**\n     * Feed list, ready for subscribe button.\n     *\n     * @param mixed      $feeds\n     * @param null|mixed $taxonomy\n     * @param null|mixed $term_id\n     *\n     * @return array list of prepared feed data-objects\n     */\n    public static function feeds($feeds, $taxonomy = null, $term_id = null)\n    {\n        $cache_key = sprintf('podlove_subscribe_button_feeds_%s_%s', $taxonomy, $term_id);\n\n        return TemplateCache::get_instance()->cache_for($cache_key, function () use ($feeds, $taxonomy, $term_id) {\n            return array_map(function ($feed) use ($taxonomy, $term_id) {\n                $file_type = $feed->episode_asset()->file_type();\n\n                $feed_data = [\n                    'type' => $file_type->type,\n                    'format' => self::feed_format($file_type->extension),\n                    'url' => $feed->get_subscribe_url($taxonomy, $term_id),\n                    'variant' => 'high',\n                ];\n\n                $itunes_feed_id = (int) $feed->itunes_feed_id;\n                if ($itunes_feed_id > 0) {\n                    $feed_data['directory-url-itunes'] = 'https://podcasts.apple.com/podcast/id'.$itunes_feed_id;\n                }\n\n                return $feed_data;\n            }, $feeds);\n        });\n    }\n\n    /**\n     * Get button compatible language string.\n     *\n     * Examples:\n     *\n     * \tlanguage('de');    // => 'de'\n     *  language('de-DE'); // => 'de'\n     *  language('en-GB'); // => 'en'\n     *\n     * @param string $language language identifier\n     *\n     * @return string\n     */\n    public static function language($language)\n    {\n        return strtolower(explode('-', $language)[0]);\n    }\n\n    private function module()\n    {\n        return Subscribe_Button::instance();\n    }\n\n    private function html()\n    {\n        if (!count($this->args['data']['feeds'])) {\n            return '';\n        }\n\n        $dataAccessor = 'podcastData'.self::get_random_string();\n\n        $dom = new \\Podlove\\DomDocumentFragment();\n\n        $script_data_tag = $dom->createElement('script');\n        $script_data_tag->appendChild(\n            $dom->createTextNode(\n                sprintf(\"window.{$dataAccessor} = %s;\", wp_json_encode($this->args['data']))\n            )\n        );\n\n        $use_cdn = $this->module()->get_module_option('use_cdn', true);\n\n        $cdn_src = 'https://cdn.podlove.org/subscribe-button/javascripts/app.js';\n        $loc_src = $this->module()->get_module_url().'/dist/javascripts/app.js';\n\n        $src = $use_cdn ? $cdn_src : $loc_src;\n\n        $script_button_tag = $this->get_script_button_tag($dom, $src, $dataAccessor);\n\n        $dom->appendChild($script_data_tag);\n        $dom->appendChild($script_button_tag);\n\n        // cdn fallback to local\n        if ($use_cdn) {\n            $dom2 = new \\Podlove\\DomDocumentFragment();\n            $tag2 = $this->get_script_button_tag($dom2, $loc_src, $dataAccessor);\n            $dom2->appendChild($tag2);\n\n            $script = trim((string) $dom2);\n            $script = str_replace('<script', '%3Cscript', $script);\n            $script = str_replace('</script>', '%3E%3C/script%3E', $script);\n            $script = str_replace('\"', '\\\"', $script);\n\n            $fallback = \"<script>\nif (typeof SubscribeButton == 'undefined') {\n\n    document.write(unescape(\\\"{$script}\\\"));\n\n    // hide uninitialized button\n    window.setTimeout(function() {\n        iframes = document.querySelectorAll('.podlove-subscribe-button-iframe')\n        for (i = 0; i < iframes.length; ++i) {\n            if (!iframes[i].style.width && !iframes[i].style.height) {\n                iframes[i].style.display = 'none';\n            }\n        }\n    }, 5000);\n\n}\n</script>\";\n        } else {\n            $fallback = '';\n        }\n\n        return ((string) $dom).$fallback;\n    }\n\n    private function get_script_button_tag($dom, $src, $accessor)\n    {\n        $tag = $dom->createElement('script');\n        $tag->setAttribute('class', 'podlove-subscribe-button');\n        $tag->setAttribute('src', $src);\n        $tag->setAttribute('data-json-data', $accessor);\n        $tag->setAttribute('data-language', self::language($this->args['language']));\n        $tag->setAttribute('data-size', self::size($this->args['size'], $this->args['width']));\n        $tag->setAttribute('data-format', $this->args['format']);\n        $tag->setAttribute('data-style', $this->args['style']);\n        $tag->setAttribute('data-color', $this->args['color']);\n\n        if ($this->args['buttonid']) {\n            $tag->setAttribute('data-buttonid', $this->args['buttonid']);\n        }\n\n        if ($this->args['hide'] && in_array($this->args['hide'], [1, '1', true, 'true', 'on'])) {\n            $tag->setAttribute('data-hide', true);\n        }\n\n        // ensure there is a closing script tag\n        $tag->appendChild($dom->createTextNode(' '));\n\n        return $tag;\n    }\n\n    /**\n     * Format string, ready for subscribe button.\n     *\n     * @param string $extension File extension of feed enclosures\n     *\n     * @return string\n     */\n    private static function feed_format($extension)\n    {\n        switch ($extension) {\n            case 'm4a': return 'aac';\n\n                break;\n            case 'oga': return 'ogg';\n\n                break;\n\n            default:\n                return $extension;\n\n                break;\n        }\n    }\n\n    /**\n     * Size string, ready for subscribe button.\n     *\n     * @param string $size  button size identifier ('small', 'medium', 'big', 'big-logo')\n     * @param string $width 'auto' for auto-width\n     *\n     * @return string\n     */\n    private static function size($size, $width)\n    {\n        if ($width == 'auto') {\n            $size .= ' auto';\n        }\n\n        return $size;\n    }\n}\n"
  },
  {
    "path": "lib/modules/subscribe_button/js/admin.js",
    "content": "function podlove_init_color_buttons() {\n    jQuery(\"#widgets-right .podlove_subscribe_color, #customize-controls .podlove_subscribe_color\").spectrum({\n        preferredFormat: 'hex',\n        showInput: true,\n        palette: [ '#75ad91' ],\n        showPalette: true,\n        showSelectionPalette: false,\n        chooseText: \"Select Color\",\n        cancelText: \"Cancel\",\n    });\n}\n\njQuery(document).ready(function () {\n    podlove_init_color_buttons();\n\n    jQuery(document).on('widget-updated', podlove_init_color_buttons);\n    jQuery(document).on('widget-added', podlove_init_color_buttons);\n\n    // re-init after saving configs\n    jQuery(document).on('ajaxComplete', function(e){\n        podlove_init_color_buttons();\n    });\n})\n\n"
  },
  {
    "path": "lib/modules/subscribe_button/subscribe_button.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SubscribeButton;\n\nuse Podlove\\Model;\n\nclass Subscribe_Button extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Subscribe Button';\n    protected $module_description = 'Use <code title=\"Shortcode for the Subscribe Button\">[podlove-podcast-subscribe-button]</code> to display a button which allows users to easily subscribe to your podcast. <a href=\"https://docs.podlove.org/podlove-publisher/reference/shortcodes/#subscribe-button\">Documentation</a>';\n    protected $module_group = 'web publishing';\n\n    public static function styles()\n    {\n        return [\n            'filled' => __('Filled', 'podlove-podcasting-plugin-for-wordpress'),\n            'outline' => __('Outline', 'podlove-podcasting-plugin-for-wordpress'),\n            'frameless' => __('Frameless', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public static function formats()\n    {\n        return [\n            'rectangle' => __('Rectangle', 'podlove-podcasting-plugin-for-wordpress'),\n            'square' => __('Square', 'podlove-podcasting-plugin-for-wordpress'),\n            'cover' => __('Cover', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public static function sizes()\n    {\n        return [\n            'small' => __('Small', 'podlove-podcasting-plugin-for-wordpress'),\n            'medium' => __('Medium', 'podlove-podcasting-plugin-for-wordpress'),\n            'big' => __('Big', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n    }\n\n    public static function languages()\n    {\n        return ['de', 'en', 'eo', 'fi', 'fr', 'nl', 'zh', 'ja'];\n    }\n\n    public function load()\n    {\n        self::register_shortcode();\n\n        add_filter(\n            'podlove_widgets',\n            function ($widgets) {\n                $widgets[] = '\\Podlove\\Modules\\SubscribeButton\\Widget';\n\n                return $widgets;\n            }\n        );\n\n        add_action('init', fn () => $this->register_option('use_cdn', 'radio', [\n            'label' => __('Use CDN?', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => '<p>'.__('Use our CDN (https://cdn.podlove.org) to always have the current version of the button on your site. Alternatively deliver the button with your own WordPress instance with the disadvantage of not using the most recent version all the time.', 'podlove-podcasting-plugin-for-wordpress').'</p>',\n            'default' => '1',\n            'options' => [\n                1 => __('yes, use CDN', 'podlove-podcasting-plugin-for-wordpress').' ('.__('recommended', 'podlove-podcasting-plugin-for-wordpress').')',\n                0 => __('no, deliver with WordPress', 'podlove-podcasting-plugin-for-wordpress'),\n            ],\n        ]));\n\n        \\Podlove\\Template\\Podcast::add_accessor(\n            'subscribeButton',\n            ['\\Podlove\\Modules\\SubscribeButton\\TemplateExtensions', 'accessorPodcastSubscribeButton'],\n            4\n        );\n    }\n\n    // shortcode function\n    public static function button($args = [])\n    {\n        if (!is_array($args)) {\n            $args = [];\n        }\n\n        $podcast = Model\\Podcast::get();\n\n        $data = [\n            'title' => $podcast->title,\n            'subtitle' => $podcast->subtitle,\n            'description' => $podcast->summary,\n            'cover' => $podcast->cover_art()->setWidth(400)->url(),\n            'feeds' => Button::feeds($podcast->feeds(['only_discoverable' => true])),\n        ];\n\n        if ($podcast->language) {\n            $args['language'] = Button::language($podcast->language);\n        }\n\n        $args = apply_filters('podlove_subscribe_button_args', $args, $podcast);\n        $data = apply_filters('podlove_subscribe_button_data', $data, $args, $podcast);\n\n        return (new Button())->render($data, $args);\n    }\n\n    public static function register_shortcode()\n    {\n        // backward compatible, but only load if no other plugin has registered this shortcode\n        if (!shortcode_exists('podlove-subscribe-button')) {\n            add_shortcode('podlove-subscribe-button', [__CLASS__, 'button']);\n        }\n\n        add_shortcode('podlove-podcast-subscribe-button', [__CLASS__, 'button']);\n    }\n}\n"
  },
  {
    "path": "lib/modules/subscribe_button/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SubscribeButton;\n\nclass TemplateExtensions\n{\n    /**\n     * Podcast Subscribe Button.\n     *\n     * **Examples**\n     *\n     * ```jinja\n     * {{ podcast.subscribeButton }}\n     * ```\n     *\n     * ```jinja\n     * {{ podcast.subscribeButton({format: 'square', color: '#000000', style: 'frameless', size: 'medium'}) }}\n     * ```\n     *\n     * **Parameters**\n     *\n     * - **show:** If you are using the \"Shows\" module, you can set the show slug. The button will then be for that show instead of the main podcast.\n     * - **format:** Choose a button format, options are 'rectangle', 'square' and 'cover' (**Note**: 'cover' has a max size of 300px) Default: 'cover'\n     * - **style:** Choose a button style, options are 'filled', 'outline' and 'frameless'. Default: 'filled'\n     * - **size:** Size and style of the button ('small', 'medium', 'big'). All of the sizes can be combined with 'auto' to adapt the button width to the available space like this: 'big auto'. Default: 'big'\n     * - **color:** Define the color of the button. Allowed are all notations for colors that CSS can understand (keyword, rgb-hex, rgb, rgba, hsl, hsla). Please Note: It is not possible to style multiple buttons/popups on the same page differently.\n     * - **language:** 'de', 'en', 'eo', 'fi', 'fr', 'nl', 'zh' and 'ja'. Defaults to podcast language setting.\n     * If you set the buttonid to \"example123\", your element must have the class \"podlove-subscribe-button-example123\".\n     * - **hide:** Set to `true` if you want to hide the default button element. Useful if you provide your own button via the `buttonid` setting.\n     * - **buttonid:** Use this if you want to trigger the button by clicking an element controlled by you.\n     *\n     * @accessor\n     *\n     * @dynamicAccessor podcast.subscribeButton\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     * @param mixed $podcast\n     * @param mixed $args\n     */\n    public static function accessorPodcastSubscribeButton($return, $method_name, $podcast, $args = [])\n    {\n        $data = [\n            'title' => $podcast->title,\n            'subtitle' => $podcast->subtitle,\n            'description' => $podcast->summary,\n            'cover' => $podcast->cover_art()->setWidth(400)->url(),\n            'feeds' => Button::feeds($podcast->feeds(['only_discoverable' => true])),\n        ];\n\n        if ($podcast->language) {\n            $args['language'] = Button::language($podcast->language);\n        }\n\n        $data = apply_filters('podlove_subscribe_button_data', $data, $args, $podcast);\n\n        return (new Button())->render($data, $args);\n    }\n}\n"
  },
  {
    "path": "lib/modules/subscribe_button/widget.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\SubscribeButton;\n\nuse Podlove\\Model\\Podcast;\n\nclass Widget extends \\WP_Widget\n{\n    public function __construct()\n    {\n        parent::__construct(\n            'podlove_subscribe_button_widget',\n            __('Podcast Subscribe Button', 'podlove-podcasting-plugin-for-wordpress'),\n            ['description' => __('Adds a Podlove Subscribe Button to your Sidebar', 'podlove-podcasting-plugin-for-wordpress')]\n        );\n\n        add_action('admin_enqueue_scripts', function () {\n            if (!in_array(get_current_screen()->base, ['widgets', 'customize'])) {\n                return;\n            }\n\n            wp_enqueue_style('podlove-spectrum', \\Podlove\\PLUGIN_URL.'/js/admin/spectrum/spectrum.css');\n            wp_register_script('podlove-spectrum', \\Podlove\\PLUGIN_URL.'/js/admin/spectrum/spectrum.js', ['jquery']);\n            wp_enqueue_script('podlove-psb-widget', Subscribe_Button::instance()->get_module_url().'/js/admin.js', ['podlove-spectrum']);\n        });\n    }\n\n    public function widget($args, $instance)\n    {\n        echo $args['before_widget'];\n\n        if (!empty($instance['title'])) {\n            echo $args['before_title'].apply_filters('widget_title', $instance['title']).$args['after_title'];\n        }\n\n        if ($instance['autowidth']) {\n            $instance['width'] = 'auto';\n        }\n\n        echo $this->button($instance);\n\n        if (!empty($instance['infotext'])) {\n            echo wpautop($instance['infotext']);\n        }\n\n        echo $args['after_widget'];\n    }\n\n    public function button($instance)\n    {\n        return Subscribe_Button::button($instance);\n    }\n\n    public function form($instance)\n    {\n        $title = isset($instance['title']) ? $instance['title'] : '';\n        $button = isset($instance['button']) ? $instance['button'] : '';\n        $size = isset($instance['size']) ? $instance['size'] : 'big';\n        $style = isset($instance['style']) ? $instance['style'] : 'filled';\n        $format = isset($instance['format']) ? $instance['format'] : 'cover';\n        $autowidth = isset($instance['autowidth']) ? $instance['autowidth'] : true;\n        $infotext = isset($instance['infotext']) ? $instance['infotext'] : '';\n        $color = isset($instance['color']) ? $instance['color'] : '#75ad91';\n\n        $subscribebutton = Podcast::get(); ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('title'); ?>\"><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" id=\"<?php echo $this->get_field_id('title'); ?>\" name=\"<?php echo $this->get_field_name('title'); ?>\" value=\"<?php echo $title; ?>\" />\n\t\t</p>\n\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('color'); ?>\"><?php _e('Color', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input type=\"text\" id=\"<?php echo $this->get_field_id('color'); ?>\" name=\"<?php echo $this->get_field_name('color'); ?>\" class=\"podlove_subscribe_color\" value=\"<?php echo $color; ?>\" />\n\t\t</p>\n\n\t\t<style type=\"text/css\">\n\t\t.sp-replacer { display: flex }\n\t\t.sp-preview { flex-grow: 10; }\n\t\t</style>\n\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('size'); ?>\"><?php _e('Size', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<select class=\"widefat\" id=\"<?php echo $this->get_field_id('size'); ?>\" name=\"<?php echo $this->get_field_name('size'); ?>\">\n\t\t\t<?php foreach (Subscribe_Button::sizes() as $size_key => $size_name) { ?>\n\t\t\t\t<option value=\"<?php echo $size_key; ?>\" <?php selected($size, $size_key); ?>><?php echo $size_name; ?></option>\n\t\t\t<?php } ?>\n\t\t\t</select>\n\t\t</p>\n\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('format'); ?>\"><?php _e('Format', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<select class=\"widefat\" id=\"<?php echo $this->get_field_id('format'); ?>\" name=\"<?php echo $this->get_field_name('format'); ?>\">\n\t\t\t<?php foreach (Subscribe_Button::formats() as $format_key => $format_name) { ?>\n\t\t\t\t<option value=\"<?php echo $format_key; ?>\" <?php selected($format, $format_key); ?>><?php echo $format_name; ?></option>\n\t\t\t<?php } ?>\n\t\t\t</select>\n\t\t</p>\n\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('style'); ?>\"><?php _e('Style', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<select class=\"widefat\" id=\"<?php echo $this->get_field_id('style'); ?>\" name=\"<?php echo $this->get_field_name('style'); ?>\">\n\t\t\t<?php foreach (Subscribe_Button::styles() as $style_key => $style_name) { ?>\n\t\t\t\t<option value=\"<?php echo $style_key; ?>\" <?php selected($style, $style_key); ?>><?php echo $style_name; ?></option>\n\t\t\t<?php } ?>\n\t\t\t</select>\n\t\t</p>\n\n\t\t<p>\n\t\t\t<input type=\"checkbox\" class=\"checkbox\" id=\"<?php echo $this->get_field_id('autowidth'); ?>\" name=\"<?php echo $this->get_field_name('autowidth'); ?>\" <?php echo $autowidth ? 'checked=\"checked\"' : ''; ?>/>\n\t\t\t<label for=\"<?php echo $this->get_field_id('autowidth'); ?>\"><?php _e('Auto-adjust width', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\t\t</p>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('infotext'); ?>\"><?php _e('Content', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<textarea class=\"widefat\" rows=\"10\" id=\"<?php echo $this->get_field_id('infotext'); ?>\" name=\"<?php echo $this->get_field_name('infotext'); ?>\"><?php echo $infotext; ?></textarea>\n\t\t\t<em>This text will be shown below the subscribe button.</em>\n\t\t</p>\n\t\t<?php\n\n        do_action('podlove_subscribe_button_widget_settings_bottom', $this, $instance);\n    }\n\n    public function update($new_instance, $old_instance)\n    {\n        $instance = [];\n        $instance['infotext'] = (!empty($new_instance['infotext'])) ? $new_instance['infotext'] : '';\n        $instance['title'] = (!empty($new_instance['title'])) ? wp_strip_all_tags($new_instance['title']) : '';\n        $instance['size'] = (!empty($new_instance['size'])) ? wp_strip_all_tags($new_instance['size']) : '';\n        $instance['format'] = (!empty($new_instance['format'])) ? wp_strip_all_tags($new_instance['format']) : '';\n        $instance['style'] = (!empty($new_instance['style'])) ? wp_strip_all_tags($new_instance['style']) : '';\n        $instance['autowidth'] = (!empty($new_instance['autowidth'])) ? wp_strip_all_tags($new_instance['autowidth']) : 0;\n        $instance['button'] = (!empty($new_instance['button'])) ? wp_strip_all_tags($new_instance['button']) : '';\n        $instance['color'] = (!empty($new_instance['color'])) ? $new_instance['color'] : '';\n\n        return apply_filters('podlove_subscribe_button_widget_settings_update', $instance, $new_instance, $old_instance);\n    }\n}\n"
  },
  {
    "path": "lib/modules/title_migration/notices.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\TitleMigration;\n\nclass Notices\n{\n    public function register_init_notice()\n    {\n        add_action('admin_notices', [$this, 'the_init_notice']);\n    }\n\n    public function register_finished_notice()\n    {\n        add_action('admin_notices', [$this, 'the_finished_notice']);\n    }\n\n    public function the_init_notice()\n    {\n        ?>\n\t\t<div class=\"notice notice-warning\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podlove Module: Title Migration', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('This update enables new episode fields introduced by Apple/iTunes iOS 11 Podcast Specification to enhance the listener experience. You need to fill in metadata fields in existing episodes to take advantage.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('Podlove Publisher provides a tool to help you update that metadata quickly.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<a class=\"button\" href=\"<?php echo admin_url('admin.php?page=podlove_tools_settings_handle#the_tools_section'); ?>\"><?php echo __('Take me to the tool', 'podlove-podcasting-plugin-for-wordpress'); ?></a> <a href=\"<?php echo self::hide_message_url(State::INITIALIZED_HIDDEN); ?>\"><?php echo __('hide this message', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t</p>\n\t\t</div>\t\n\t\t<?php\n    }\n\n    public function the_finished_notice()\n    {\n        ?>\n\t\t<div class=\"notice notice-warning\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Podlove Module: Title Migration', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('You are done migrating your episode titles. You can deactivate the title migration module.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<a class=\"button\" href=\"<?php echo admin_url('admin.php?page=podlove_settings_modules_handle&podlove_disable_title_migration_module=1'); ?>\"><?php echo __('Deactivate Title Migration Module', 'podlove-podcasting-plugin-for-wordpress'); ?></a> <a href=\"<?php echo self::hide_message_url(State::FINISHED_HIDDEN); ?>\"><?php echo __('hide this message', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t</p>\n\t\t</div>\t\n\t\t<?php\n    }\n\n    public static function hide_message_url($state)\n    {\n        if (isset($_REQUEST['page']) && $_REQUEST['page']) {\n            return admin_url('admin.php?page='.$_REQUEST['page'].'&podlove_set_title_migration_state='.$state);\n        }\n\n        return admin_url('admin.php?page=podlove_tools_settings_handle&podlove_set_title_migration_state='.$state);\n    }\n}\n"
  },
  {
    "path": "lib/modules/title_migration/state.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\TitleMigration;\n\n/**\n * Describes the state of the migration workflow.\n *\n * - INITIALIZED\n * \t-> show admin message where to find the tool\n * - FINISHED\n * \t-> show admin message to deactivate the module\n * - FINISHED_HIDDEN\n *  -> finished but message hidden\n */\nclass State\n{\n    public const INITIALIZED = 'initialized';\n    public const INITIALIZED_HIDDEN = 'initialized_hidden';\n    public const FINISHED = 'finished';\n    public const FINISHED_HIDDEN = 'finished_hidden';\n\n    public const OPTION = 'podlove_title_migration_state';\n\n    public function is_initialized()\n    {\n        return $this->get_current_state() == self::INITIALIZED;\n    }\n\n    public function is_initialized_hidden()\n    {\n        return $this->get_current_state() == self::INITIALIZED_HIDDEN;\n    }\n\n    public function is_finished()\n    {\n        return $this->get_current_state() == self::FINISHED;\n    }\n\n    public function is_finished_hidden()\n    {\n        return $this->get_current_state() == self::FINISHED_HIDDEN;\n    }\n\n    public function get_current_state()\n    {\n        return get_option(self::OPTION, self::INITIALIZED);\n    }\n\n    /**\n     * Set current state.\n     *\n     * Only accepts valid states.\n     *\n     * @param string $state\n     *\n     * @return bool false if state is invalid, update failed or state was not changed. Otherwise true.\n     */\n    public function set_current_state($state)\n    {\n        if (!in_array($state, $this->states())) {\n            return false;\n        }\n\n        return update_option(self::OPTION, $state);\n    }\n\n    public function states()\n    {\n        return [\n            self::INITIALIZED,\n            self::INITIALIZED_HIDDEN,\n            self::FINISHED,\n            self::FINISHED_HIDDEN,\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/modules/title_migration/title_migration.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\TitleMigration;\n\nuse Podlove\\Model;\n\nclass Title_Migration extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Title Migration';\n    protected $module_description = 'Tool to help you fill episode number and title fields introduced in Publisher 2.7 for new Apple iOS 11 podcast feed extensions.';\n    protected $module_group = 'system';\n\n    protected $state;\n    protected $notices;\n\n    public function load()\n    {\n        add_action('admin_init', [$this, 'add_tools_section']);\n\n        $this->state = new State();\n        $this->notices = new Notices();\n\n        if (isset($_POST['action']) && $_POST['action'] === 'podlove_migrate_titles') {\n            $this->handle_migration();\n        }\n\n        if (isset($_REQUEST['podlove_set_title_migration_state'])) {\n            $this->state->set_current_state($_REQUEST['podlove_set_title_migration_state']);\n        }\n\n        if (isset($_REQUEST['podlove_disable_title_migration_module']) && $_REQUEST['podlove_disable_title_migration_module']) {\n            $this->state->set_current_state(State::FINISHED_HIDDEN);\n            self::deactivate('title_migration');\n            add_action('admin_notices', function () {\n                ?>\n\t\t\t\t<div id=\"message\" class=\"notice notice-success\">\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<strong><?php echo sprintf(\n\t\t\t\t\t\t    __('Module \"%s\" was deactivated.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    $this->get_module_name()\n\t\t\t\t\t\t); ?></strong>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            });\n        }\n\n        if ($this->state->is_initialized()) {\n            $this->notices->register_init_notice();\n        } elseif ($this->state->is_finished()) {\n            $this->notices->register_finished_notice();\n        }\n    }\n\n    public function add_tools_section()\n    {\n        \\Podlove\\add_tools_section(\n            'title-migration',\n            __('Migrate Episode Titles', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'the_tools_section']\n        );\n    }\n\n    public function handle_migration()\n    {\n        if (!$this->nonce_is_valid()) {\n            echo 'Sorry, your nonce did not verify.';\n            exit;\n        }\n\n        if (!isset($_POST['migrate']) || !is_array($_POST['migrate'])) {\n            return;\n        }\n\n        // mnemonic setting\n        $podcast = Model\\Podcast::get();\n        $podcast->mnemonic = $_POST['migrate_mnemonic'];\n        $podcast->save();\n\n        // autogen setting\n        $website_settings = get_option('podlove_website');\n        $website_settings['enable_generated_blog_post_title'] = isset($_POST['podlove_website']['enable_generated_blog_post_title']) ? $_POST['podlove_website']['enable_generated_blog_post_title'] : false;\n        $website_settings['blog_title_template'] = $_POST['podlove_website']['blog_title_template'];\n        update_option('podlove_website', $website_settings);\n\n        // episodes\n        $episodes = $_POST['migrate'];\n\n        foreach ($episodes as $episode_id => $data) {\n            $episode = Model\\Episode::find_by_id($episode_id);\n            $episode->number = (int) $data['number'];\n            $episode->title = trim($data['title']);\n            $episode->type = 'full';\n            $episode->save();\n        }\n\n        $this->state->set_current_state(State::FINISHED);\n    }\n\n    public function nonce_is_valid()\n    {\n        return isset($_POST['podlove_migrate_titles_nonce'])\n            && wp_verify_nonce($_POST['podlove_migrate_titles_nonce'], 'podlove_migrate_titles');\n    }\n\n    public function the_tools_section()\n    {\n        $episodes = Model\\Episode::find_all_by_time();\n        $episodes = array_map(function ($episode) {\n            $post_title = get_post($episode->post_id)->post_title;\n            $guess = $this->guess_metadata_from_title($post_title);\n\n            return [\n                'episode' => $episode,\n                'post_title' => $post_title,\n                'title_guess' => $guess['title'],\n                'number_guess' => $guess['number'],\n            ];\n        }, $episodes); ?>\n<div id=\"the_tools_section\"></div>\n<form method=\"POST\">\n\n\t<input type=\"hidden\" name=\"action\" value=\"podlove_migrate_titles\">\n\t<?php wp_nonce_field('podlove_migrate_titles', 'podlove_migrate_titles_nonce'); ?>\n\n\t<p class=\"description\">\n\t\t<?php echo __('There are new fields in podcast feeds for episode numbers and clean titles. You can edit them one by one using the episode screen, or use this tool to update them all at once.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t</p>\n\n\t<h4>Mnemonic</h4>\n\n\t<input type=\"text\" name=\"migrate_mnemonic\" id=\"migrate_mnemonic\" value=\"<?php echo podlove_get_mnemonic(); ?>\" class=\"regular-text required podlove-check-input\">\n\n\t<p class=\"description\">\n\t\t<?php echo __('Abbreviation for your podcast. Usually 2–4 capital letters, used to reference episodes. For example, the podcast \"The Lunatic Fringe\" might have the mnemonic TLF and its fifth episode can be referred to via TLF005.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t</p>\n\n\t<p class=\"description\">\n\t\t<?php echo sprintf(\n\t\t    __('You can find this setting later at %sPodcast Settings%s.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t    '<a href=\"'.admin_url('admin.php?page=podlove_settings_podcast_handle').'\">',\n\t\t    '</a>'\n\t\t); ?>\n\t\t\n\t</p>\n\n\t<h4><?php echo __('Always automatically generate blog episode titles?', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\n\t<?php\n    $enable_generated_blog_post_title = \\Podlove\\get_setting('website', 'enable_generated_blog_post_title');\n        $blog_title_template = \\Podlove\\get_setting('website', 'blog_title_template');\n\n        \\Podlove\\load_template('expert_settings/website/blog_post_title', compact('enable_generated_blog_post_title', 'blog_title_template')); ?>\n\n\t<p class=\"description\">\n\t\t<?php echo sprintf(\n\t\t    __('You can find this setting later at %sExpert Settings > Website > Blog Episode Titles%s.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t    '<a href=\"'.admin_url('admin.php?page=podlove_settings_settings_handle').'\">',\n\t\t    '</a>'\n\t\t); ?>\n\t\t\n\t</p>\n\n\t<table>\n\t\t<thead>\n\t\t\t<tr style=\"text-align: left\">\n\t\t\t\t<th><?php echo __('Current Post Title', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t<th><?php echo __('Episode Number', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t<th><?php echo __('Episode Title', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t<?php foreach ($episodes as $episode) { ?>\n\t\t\t\t<tr>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo $episode['post_title']; ?>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<input type=\"text\" value=\"<?php echo esc_attr($episode['number_guess']); ?>\" name=\"migrate[<?php echo (int) $episode['episode']->id; ?>][number]\" class=\"regular-text\" style=\"width: 125px\" />\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<input type=\"text\" value=\"<?php echo esc_attr($episode['title_guess']); ?>\" name=\"migrate[<?php echo (int) $episode['episode']->id; ?>][title]\" class=\"regular-text\" />\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t<?php } ?>\n\t\t</tbody>\n\t</table>\n\n\t<?php\n    $input_count = count($episodes) * 2 + 4;\n        $buffer = 10;\n        if (ini_get('max_input_vars') < $input_count) {\n            ?>\n\t\t<div class=\"podlove-warning\" style=\"border-left: 5px solid rgba(212, 61, 4, 1.000); padding-left: 5px;\">\n\t\t\t<strong>Lots of episodes! This might not work.</strong>\n\n\t\t\tPHP has a limit fow how many form fields can be sent at once.\n\t\t\tIt looks like this needs more than is allowed here.\n\t\t\tYou should increase it in your php.ini.\n\t\t\tAsk your hoster if you are not sure about this.\n\n<pre>\n# currently\nini_get('max_input_vars') = <?php echo ini_get('max_input_vars'); ?>\n\n# required\n<?php echo $input_count + $buffer; ?>\n\n# php.ini recommendation\nmax_input_vars = <?php echo $input_count + $buffer; ?>\n</pre>\n\t\t</div>\n\t<?php\n        } ?>\n\n\t<button class=\"button button-primary\">\n\t\t<?php echo __('Migrate Post Titles', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t</button>\n\n</form>\t\n\t\t<?php\n    }\n\n    public function guess_metadata_from_title($post_title)\n    {\n        if (preg_match('/\\d+/', $post_title, $matches, PREG_OFFSET_CAPTURE)) {\n            $number = (int) $matches[0][0];\n            $offset = $matches[0][1] + strlen($matches[0][0]);\n        } else {\n            $number = null;\n            $offset = 0;\n        }\n\n        $title = substr($post_title, $offset);\n        $title = trim($title);\n        $title = preg_replace('/^[-–~\\s|:]+/', '', $title);\n\n        return [\n            'title' => $title,\n            'number' => $number,\n        ];\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/jobs/import_transcripts_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass ImportTranscriptsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Transcripts';\n    }\n\n    public static function description()\n    {\n        return 'Imports Episode Transcripts';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return \\Podlove\\Modules\\Transcripts\\Model\\Transcript::class;\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'transcript';\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/jobs/import_voice_assignments_job.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Jobs;\n\nuse Podlove\\Jobs\\JobTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTableTrait;\nuse Podlove\\Modules\\ImportExport\\Import\\PodcastImportJobTrait;\n\nclass ImportVoiceAssignmentsJob\n{\n    use JobTrait,\n        PodcastImportJobTrait,\n        PodcastImportJobTableTrait {\n            PodcastImportJobTableTrait::setup insteadof JobTrait;\n        }\n\n    public static function title()\n    {\n        return 'Podcast Import: Transcript Voices';\n    }\n\n    public static function description()\n    {\n        return 'Imports Episode Transcript Voice Assignments';\n    }\n\n    protected static function get_import_table_class()\n    {\n        return \\Podlove\\Modules\\Transcripts\\Model\\VoiceAssignment::class;\n    }\n\n    protected static function get_import_item_name()\n    {\n        return 'voice_assignment';\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/model/transcript.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Model;\n\nclass Transcript extends \\Podlove\\Model\\Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function exists_for_episode($episode_id)\n    {\n        global $wpdb;\n\n        $sql = 'SELECT id FROM '\n        .static::table_name()\n        .' WHERE episode_id = '.(int) $episode_id\n            .' LIMIT 1';\n\n        return $wpdb->get_var($sql) > 0;\n    }\n\n    public static function delete_for_episode($episode_id)\n    {\n        global $wpdb;\n\n        $sql = 'DELETE FROM '\n        .static::table_name()\n        .' WHERE episode_id = '.(int) $episode_id;\n\n        return $wpdb->query($sql);\n    }\n\n    public static function get_voices_for_episode_id($episode_id)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT DISTINCT t.voice, va.`contributor_id`\n\t\t\tFROM '.static::table_name().' t\n\t\t\tLEFT JOIN '.VoiceAssignment::table_name().' va\n\t\t\t  ON va.`episode_id` = t.`episode_id` AND va.voice = t.voice\n\t\t\tWHERE t.voice IS NOT NULL\n\t\t\t  AND t.episode_id = '.(int) $episode_id;\n\n        return $wpdb->get_results($sql);\n    }\n\n    public static function get_transcript($episode_id)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT t.start, t.end, t.content, t.voice, va.contributor_id\n\t\t\tFROM '.static::table_name().' t\n\t\t\tLEFT JOIN '.VoiceAssignment::table_name().' va ON va.`episode_id` = t.`episode_id` AND va.voice = t.voice\n\t\t\tLEFT JOIN '.\\Podlove\\Modules\\Contributors\\Model\\Contributor::table_name().' c ON c.id = va.contributor_id\n\t\t\tWHERE t.episode_id = '.(int) $episode_id.'\n\t\t\tORDER BY t.start ASC';\n\n        return $wpdb->get_results($sql);\n    }\n\n    public static function get_transcript_offset_limit($episode_id, $offset, $limit)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT t.id, t.start, t.end, t.content, t.voice, va.contributor_id\n\t\t\tFROM '.static::table_name().' t\n\t\t\tLEFT JOIN '.VoiceAssignment::table_name().' va ON va.`episode_id` = t.`episode_id` AND va.voice = t.voice\n\t\t\tLEFT JOIN '.\\Podlove\\Modules\\Contributors\\Model\\Contributor::table_name().' c ON c.id = va.contributor_id\n\t\t\tWHERE t.episode_id = '.(int) $episode_id.'\n            ORDER BY t.start ASC LIMIT '.(int) $limit.' OFFSET '.(int) $offset;\n\n        return $wpdb->get_results($sql);\n    }\n\n    public static function get_transcript_count($episode_id)\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT COUNT(t.start)\n\t\t\tFROM '.static::table_name().' t\n\t\t\tLEFT JOIN '.VoiceAssignment::table_name().' va ON va.`episode_id` = t.`episode_id` AND va.voice = t.voice\n\t\t\tLEFT JOIN '.\\Podlove\\Modules\\Contributors\\Model\\Contributor::table_name().' c ON c.id = va.contributor_id\n\t\t\tWHERE t.episode_id = '.(int) $episode_id;\n\n        return $wpdb->get_var($sql);\n    }\n\n    /**\n     * Prepares transcript from database for further processing or viewing.\n     *\n     * Example\n     *\n     *   $transcript = Transcript::get_transcript($episode_id);\n     *   $transcript = Transcript::prepare_transcript($transcript, 'grouped');\n     *\n     * @param mixed $transcript\n     * @param mixed $mode\n     * @param mixed $allow_empty_contributors\n     */\n    public static function prepare_transcript($transcript, $mode = 'flat', $allow_empty_contributors = false)\n    {\n        $original_transcript = $transcript;\n\n        $transcript = array_map(function ($t) use ($allow_empty_contributors) {\n            if (!$t->contributor_id && !$allow_empty_contributors) {\n                return null;\n            }\n\n            return [\n                'start' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->start),\n                'start_ms' => (int) $t->start,\n                'end' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->end),\n                'end_ms' => (int) $t->end,\n                'speaker' => $t->contributor_id,\n                'voice' => $t->voice,\n                'text' => $t->content,\n            ];\n        }, $transcript);\n\n        $transcript = array_filter($transcript);\n        $transcript = array_values($transcript);\n\n        // if the processed transcript is empty, maybe there are no assigned contributors, so try again without requiring contributors\n        if (empty($transcript) && !$allow_empty_contributors) {\n            return self::prepare_transcript($original_transcript, $mode, true);\n        }\n\n        if ($mode != 'flat') {\n            $transcript = array_reduce($transcript, function ($agg, $item) {\n                if (empty($agg)) {\n                    $agg['items'] = [];\n                    $agg['prev_speaker'] = null;\n                    $agg['prev_voice'] = null;\n                }\n\n                $speaker = $item['speaker'];\n                unset($item['speaker']);\n\n                $voice = $item['voice'];\n                unset($item['voice']);\n\n                if ($agg['prev_voice'] == $voice) {\n                    $agg['items'][count($agg['items']) - 1]['items'][] = $item;\n                } else {\n                    $agg['items'][] = [\n                        'speaker' => $speaker,\n                        'voice' => $voice,\n                        'items' => [$item],\n                    ];\n                }\n\n                $agg['prev_speaker'] = $speaker;\n                $agg['prev_voice'] = $voice;\n\n                return $agg;\n            }, []);\n            $transcript = $transcript['items'] ?? [];\n        }\n\n        return $transcript;\n    }\n}\n\nTranscript::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nTranscript::property('episode_id', 'INT');\nTranscript::property('start', 'INT UNSIGNED');\nTranscript::property('end', 'INT UNSIGNED');\nTranscript::property('voice', 'VARCHAR(255)');\nTranscript::property('content', 'TEXT');\n"
  },
  {
    "path": "lib/modules/transcripts/model/voice_assignment.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Model;\n\nclass VoiceAssignment extends \\Podlove\\Model\\Base\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    public function __construct()\n    {\n        $this->set_blog_id();\n    }\n\n    public static function delete_for_episode($episode_id)\n    {\n        global $wpdb;\n\n        $sql = 'DELETE FROM '\n        .static::table_name()\n        .' WHERE episode_id = '.(int) $episode_id;\n\n        return $wpdb->query($sql);\n    }\n\n    public static function is_voice_set($episode_id, $voice)\n    {\n        return (bool) self::find_one_by_where(\n            sprintf('`episode_id` = \"%d\" AND `voice` = \"%s\"', (int) $episode_id, esc_sql($voice))\n        );\n    }\n}\n\nVoiceAssignment::property('id', 'INT NOT NULL AUTO_INCREMENT PRIMARY KEY');\nVoiceAssignment::property('episode_id', 'INT');\nVoiceAssignment::property('voice', 'VARCHAR(255)');\nVoiceAssignment::property('contributor_id', 'INT UNSIGNED');\n"
  },
  {
    "path": "lib/modules/transcripts/renderer.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Transcripts\\Model\\Transcript;\n\n/**\n * Transcript renderer.\n *\n * Renders an episode transcript as JSON or webvtt.\n *\n * EXAMPLE\n *\n *     $renderer = new Renderer($episode);\n *\n *     header(\"Content-Type: text/vtt\");\n *     echo $renderer->as_webvtt();\n *     exit;\n */\nclass Renderer\n{\n    private $episode;\n\n    public function __construct(Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    /**\n     * Render transcript as JSON.\n     *\n     * Supports two modes:\n     *\n     *   - flat: same structure as webvtt, just as json\n     *   - grouped: all subsequent items with the same speaker are grouped\n     *\n     * @param string $mode 'flat' or 'grouped'\n     *\n     * @return string\n     */\n    public function as_json($mode = 'flat')\n    {\n        return wp_json_encode($this->get_data($mode));\n    }\n\n    /**\n     * Render transcript as JSON according to podcastindex spec.\n     *\n     * @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/transcripts/transcripts.md#json\n     *\n     * @return string\n     */\n    public function as_podcastindex_json()\n    {\n        $data = array_map(function ($entry) {\n            return [\n                'speaker' => $entry['voice'],\n                'startTime' => $entry['start_ms'] / 1000,\n                'endTime' => $entry['end_ms'] / 1000,\n                'body' => $entry['text']\n            ];\n        }, $this->get_data());\n\n        return wp_json_encode(['version' => '1.0.0', 'segments' => $data]);\n    }\n\n    public function as_xml()\n    {\n        $xml = new \\SimpleXMLElement(\n            '<?xml version=\"1.0\" encoding=\"UTF-8\"?>'\n            .'<pst:transcripts version=\"1.0\" xmlns:pst=\"http://podlove.org/simple-transcripts\" />'\n        );\n\n        $data = $this->get_data('grouped');\n\n        foreach ($data as $group) {\n            $groupXML = $xml->addChild('pst:speech');\n            $groupXML->addChild('pst:speaker', $group['speaker']);\n            foreach ($group['items'] as $item) {\n                $child = $groupXML->addChild('pst:item', $item['text']);\n                $child->addAttribute('start', $item['start']);\n                $child->addAttribute('end', $item['end']);\n            }\n        }\n\n        $xml_string = $xml->asXML();\n\n        return $this->format_xml($xml_string);\n    }\n\n    public function as_webvtt()\n    {\n        $voices = Transcript::get_voices_for_episode_id($this->episode->id);\n        $contributors_map = [];\n\n        foreach ($voices as $voice) {\n            $contributors_map[$voice->voice] = Contributor::find_by_id($voice->contributor_id);\n        }\n\n        $pretty_voice = function ($voice) use ($contributors_map) {\n            if (isset($contributors_map[$voice])) {\n                $contributor = $contributors_map[$voice];\n                $voice_title = ($contributor && $contributor->getName()) ? $contributor->getName() : $voice;\n\n                if ($voice_title) {\n                    return \"<v {$voice_title}>\";\n                }\n            }\n\n            return '';\n        };\n\n        $transcript = array_map(fn ($entry) => sprintf(\n            \"%s --> %s\\n%s%s\",\n            $entry['start'],\n            $entry['end'],\n            $pretty_voice($entry['voice']),\n            $entry['text']\n        ), $this->get_data());\n\n        $transcript = array_filter($transcript);\n\n        $note = \"NOTE\\n\";\n        $note .= 'Podcast: '.Podcast::get()->title.\"\\n\";\n        $note .= 'Episode: '.$this->episode->title().\"\\n\";\n        $note .= 'Publishing Date: '.get_the_date('c', $this->episode->post_id).\"\\n\";\n        $note .= 'Podcast URL: '.Podcast::get()->landing_page_url().\"\\n\";\n        $note .= 'Episode URL: '.get_permalink($this->episode->post_id).\"\\n\";\n        $note .= \"\\n\";\n\n        return \"WEBVTT\\n\\n\".$note.implode(\"\\n\\n\", $transcript).\"\\n\";\n    }\n\n    public static function format_time($time_ms)\n    {\n        $ms = $time_ms % 1000;\n        $seconds = floor($time_ms / 1000) % 60;\n        $minutes = floor($time_ms / (1000 * 60)) % 60;\n        $hours = (int) floor($time_ms / (1000 * 60 * 60));\n\n        return sprintf('%02d:%02d:%02d.%03d', $hours, $minutes, $seconds, $ms);\n    }\n\n    private function format_xml($xml)\n    {\n        $dom = new \\DOMDocument('1.0', 'utf-8');\n        $dom->preserveWhiteSpace = false;\n        $dom->formatOutput = true;\n        $dom->loadXML($xml);\n\n        return $dom->saveXML();\n    }\n\n    private function get_data($mode = 'flat')\n    {\n        return Transcript::prepare_transcript(\n            Transcript::get_transcript($this->episode->id),\n            $mode\n        );\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/rest_api.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Transcripts\\Model\\Transcript;\nuse Podlove\\Modules\\Transcripts\\Model\\VoiceAssignment;\n\nclass REST_API\n{\n    public const api_namespace = 'podlove/v1';\n    public const api_base = 'transcripts';\n\n    public function register_routes()\n    {\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)/voices', [\n            'args' => [\n                'id' => [\n                    'description' => __('post id'),\n                    'type' => 'integer'\n                ]\n            ],\n            [\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_voices'],\n                'permission_callback' => [$this, 'permission_check']\n            ]\n        ]);\n\n        register_rest_route(self::api_namespace, self::api_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('episode id'),\n                    'type' => 'integer'\n                ]\n            ],\n            [\n                'methods' => 'GET',\n                'callback' => [$this, 'get_transcript'],\n                'permission_callback' => '__return_true'\n            ]\n        ]);\n    }\n\n    public function update_voices($request)\n    {\n        $post_id = $request['id'];\n        $episode = Episode::find_one_by_post_id($post_id);\n\n        if (!$episode) {\n            return new \\WP_Error('podlove_rest_episode_not_found', 'episode does not exist', ['status' => 400]);\n        }\n\n        VoiceAssignment::delete_for_episode($episode->id);\n\n        if (is_array($request['transcript_voice'])) {\n            foreach ($request['transcript_voice'] as $voice => $id) {\n                $voice_assignment = new VoiceAssignment();\n                $voice_assignment->episode_id = $episode->id;\n                $voice_assignment->voice = $voice;\n                $voice_assignment->contributor_id = (int) $id;\n                $voice_assignment->save();\n            }\n        }\n\n        return rest_ensure_response(['status' => 'ok']);\n    }\n\n    public function get_transcript($request)\n    {\n        $episode_id = $request->get_param('id');\n        $mode = $request->get_param('mode') ?? 'flat';\n\n        if ($mode != 'flat' && $mode != 'grouped') {\n            return new \\WP_Error('podlove_rest_episode_invalid_parameter', 'paramenter mode only allows flat or grouped', ['status' => 400]);\n        }\n\n        return Transcript::prepare_transcript(Transcript::get_transcript($episode_id));\n    }\n\n    public function permission_check()\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\WP_Error('rest_forbidden', 'sorry, you do not have permissions to use this REST API endpoint', ['status' => 401]);\n        }\n\n        return true;\n    }\n}\n\nclass WP_REST_PodloveTranscripts_Controller extends \\WP_REST_Controller\n{\n    public function __construct()\n    {\n        $this->namespace = 'podlove/v2';\n        $this->rest_base = 'transcripts';\n    }\n\n    public function register_routes()\n    {\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                    'required' => 'true'\n                ],\n            ],\n            [\n                'args' => [\n                    'limit' => [\n                        'description' => __('How many entries should be delivered?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'offset' => [\n                        'description' => __('From which entry should the data be delivered?', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ],\n                    'count' => [\n                        'description' => __('How many entries are there? Ignored limit and offset.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_items'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'content' => [\n                        'description' => __('Transcription file as plain text utf-8 encoded', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true'\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::CREATABLE,\n                'callback' => [$this, 'create_item'],\n                'permission_callback' => [$this, 'create_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'content' => [\n                        'description' => __('Transcription file', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'required' => 'true'\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/voices/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                    'required' => 'true'\n                ],\n            ],\n            [\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_voices'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'voice' => [\n                        'description' => __('Voice', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'contributor_id' => [\n                        'description' => __('Contributor Id assigned to the voice.', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'integer',\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_voices'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ]\n        ]);\n        register_rest_route($this->namespace, '/'.$this->rest_base.'/paragraphs/(?P<id>[\\d]+)', [\n            'args' => [\n                'id' => [\n                    'description' => __('Unique identifier for the part of the transcription (called chaption).', 'podlove-podcasting-plugin-for-wordpress'),\n                    'type' => 'integer',\n                    'required' => 'true'\n                ]\n            ],\n            [\n                'args' => [\n                    'start' => [\n                        'description' => __('Timestamp begin of the chaption', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'end' => [\n                        'description' => __('Timestamp end of the chaption', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'voices' => [\n                        'description' => __('Name of the speaker', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ],\n                    'content' => [\n                        'description' => __('Content', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'methods' => \\WP_REST_Server::READABLE,\n                'callback' => [$this, 'get_item_transcripts'],\n                'permission_callback' => [$this, 'get_item_permissions_check'],\n            ],\n            [\n                'args' => [\n                    'start' => [\n                        'description' => __('Timestamp begin of the paragraph', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::timestamp'\n                    ],\n                    'end' => [\n                        'description' => __('Timestamp end of the paragraph', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                        'validate_callback' => '\\Podlove\\Api\\Validation::timestamp'\n                    ],\n                    'text' => [\n                        'description' => __('Content', 'podlove-podcasting-plugin-for-wordpress'),\n                        'type' => 'string',\n                    ]\n                ],\n                'description' => __('Edit a chaption of the transcript', 'podlove-podcasting-plugin-for-wordpress'),\n                'methods' => \\WP_REST_Server::EDITABLE,\n                'callback' => [$this, 'update_item_transcripts'],\n                'permission_callback' => [$this, 'update_item_permissions_check'],\n            ],\n            [\n                'description' => __('Delete a chaption of the transcript', 'podlove-podcasting-plugin-for-wordpress'),\n                'methods' => \\WP_REST_Server::DELETABLE,\n                'callback' => [$this, 'delete_item_transcripts'],\n                'permission_callback' => [$this, 'delete_item_permissions_check'],\n            ]\n        ]);\n    }\n\n    public function get_item_permissions_check($request)\n    {\n        return true;\n    }\n\n    public function get_items($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $limit = 0;\n        $offset = 0;\n\n        if (isset($request['limit'])) {\n            $limit = $request['limit'];\n        }\n\n        if (isset($request['offset'])) {\n            $offset = $request['offset'];\n        }\n\n        if (isset($request['count'])) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'count' => Transcript::get_transcript_count($id),\n            ]);\n        }\n\n        if ($offset === 0 && $limit === 0) {\n            $transcript = Transcript::get_transcript($id);\n            $transcript = array_map(function ($t) {\n                return [\n                    'start' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->start),\n                    'start_ms' => (int) $t->start,\n                    'end' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->end),\n                    'end_ms' => (int) $t->end,\n                    'voice' => $t->voice,\n                    'text' => $t->content,\n                ];\n            }, $transcript);\n\n            $transcript = array_filter($transcript);\n            $transcript = array_values($transcript);\n\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'transcript' => $transcript,\n            ]);\n        }\n\n        $count = Transcript::get_transcript_count($id);\n\n        $transcript = Transcript::get_transcript_offset_limit($id, $offset, $limit);\n        $transcript = array_map(function ($t) {\n            return [\n                'id' => (int) $t->id,\n                'start' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->start),\n                'start_ms' => (int) $t->start,\n                'end' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->end),\n                'end_ms' => (int) $t->end,\n                'voice' => $t->voice,\n                'text' => $t->content,\n            ];\n        }, $transcript);\n\n        $transcript = array_filter($transcript);\n        $transcript = array_values($transcript);\n\n        $next_url = '';\n        $prev_url = '';\n\n        $next = $offset + $limit;\n        if ($next < $count) {\n            $next_url = $this->namespace.'/'.$this->rest_base.'/'.$id.'?offset='.$next.'?limit='.$limit;\n        }\n\n        $prev = $offset - $limit;\n        if ($prev > 0) {\n            $prev_url = $this->namespace.'/'.$this->rest_base.'/'.$id.'?offset='.$prev.'?limit='.$limit;\n        }\n\n        if ($prev_url && $next_url) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'prev' => $prev_url,\n                'next' => $next_url,\n                'transcript' => $transcript,\n            ]);\n        }\n\n        if ($prev_url) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'prev' => $prev_url,\n                'transcript' => $transcript,\n            ]);\n        }\n        if ($next_url) {\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                '_version' => 'v2',\n                'next' => $next_url,\n                'transcript' => $transcript,\n            ]);\n        }\n    }\n\n    public function get_item_transcripts($request)\n    {\n        $id = $request->get_param('id');\n        $transcript = Transcript::find_by_id($id);\n\n        if (!$transcript) {\n            return new \\Podlove\\Api\\Error\\NotFound(\n                'not_found',\n                'transcript with id '.$id.' was not found'\n            );\n        }\n\n        $data = [\n            '_version' => 'v2',\n            'id' => $id,\n            'episode' => $transcript->episode_id,\n            'start' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($transcript->start),\n            'start_ms' => $transcript->start,\n            'end' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($transcript->end),\n            'end_ms' => $transcript->end,\n            'voice' => $transcript->voice,\n            'text' => $transcript->content\n        ];\n\n        return new \\Podlove\\Api\\Response\\OkResponse($data);\n    }\n\n    public function get_item_voices($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        $data = Transcript::get_voices_for_episode_id($id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            '_version' => 'v2',\n            'voices' => $data\n        ]);\n    }\n\n    public function create_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function create_item($request)\n    {\n        return $this->update_item($request);\n    }\n\n    public function update_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function update_item($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound('not_found', 'Episode not found');\n        }\n\n        $file_content = '';\n\n        if (isset($request['content'])) {\n            $file_content = $request['content'];\n\n            if (function_exists('mb_check_encoding') && !mb_check_encoding($file_content, 'UTF-8')) {\n                \\Podlove\\Api\\Error\\NotSupported('not_supported', 'Error parsing webvtt file: must be UTF-8 encoded.');\n            }\n\n            $result = Transcripts::parse_webvtt($file_content);\n\n            if ($result === false) {\n                return new \\Podlove\\Api\\Error\\InternalServerError('internal_server_error', 'Sorry, we can not parse your vtt content.');\n            }\n\n            Transcript::delete_for_episode($episode->id);\n            VoiceAssignment::delete_for_episode($episode->id);\n\n            foreach ($result['cues'] as $cue) {\n                $line = new Transcript();\n                $line->episode_id = $episode->id;\n                $line->start = $cue['start'] * 1000;\n                $line->end = $cue['end'] * 1000;\n                $line->voice = $cue['voice'];\n                $line->content = $cue['text'];\n                $line->save();\n            }\n\n            $voices = array_unique(array_map(function ($cue) {\n                return $cue['voice'];\n            }, $result['cues']));\n\n            foreach ($voices as $voice) {\n                $contributor = Contributor::find_one_by_property('identifier', $voice);\n\n                if (!VoiceAssignment::is_voice_set($episode->id, $voice) && $contributor) {\n                    $voice_assignment = new VoiceAssignment();\n                    $voice_assignment->episode_id = $episode->id;\n                    $voice_assignment->voice = $voice;\n                    $voice_assignment->contributor_id = $contributor->id;\n                    $voice_assignment->save();\n                }\n            }\n        } else {\n            if (isset($request['asset'])) {\n                if (Transcripts::transcript_import_from_asset($episode) !== true) {\n                    return new \\Podlove\\Api\\Error\\InternalServerError('internal_server_error', 'Sorry, we can not import the transcript from an asset.');\n                }\n            }\n        }\n\n        if (isset($request['content']) || isset($request['asset'])) {\n            $transcript = Transcript::get_transcript($episode->id);\n            $transcript = array_map(function ($t) {\n                return [\n                    'start' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->start),\n                    'start_ms' => (int) $t->start,\n                    'end' => \\Podlove\\Modules\\Transcripts\\Renderer::format_time($t->end),\n                    'end_ms' => (int) $t->end,\n                    'voice' => $t->voice,\n                    'text' => $t->content,\n                ];\n            }, $transcript);\n\n            return new \\Podlove\\Api\\Response\\OkResponse([\n                'status' => 'ok',\n                'transcript' => $transcript\n            ]);\n        }\n\n        return new \\Podlove\\Api\\Response\\OkResponse();\n    }\n\n    public function update_item_voices($request)\n    {\n        $id = $request->get_param('id');\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound('not_found', 'Episode not found');\n        }\n\n        $voice_assignment = null;\n\n        if (isset($request['voice'])) {\n            $voice = $request['voice'];\n            $voice_assignment = VoiceAssignment::find_one_by_where(\n                sprintf('`episode_id` = \"%d\" AND `voice` = \"%s\"', (int) $id, esc_sql($voice))\n            );\n            if (!$voice_assignment) {\n                $voice_assignment = new VoiceAssignment();\n                $voice_assignment->episode_id = $episode->id;\n                $voice_assignment->voice = $voice;\n                $voice_assignment->contributer_id = 0;\n            }\n        }\n\n        $cid = 0;\n\n        if (isset($request['contributor_id'])) {\n            $cid = (int) $request['contributor_id'];\n            if ($cid > 0) {\n                $contributor = Contributor::find_by_id($cid);\n                if (!$contributor) {\n                    return new \\Podlove\\Api\\Error\\NotFound('not_found', 'Contributor is not found');\n                }\n            }\n        }\n\n        $voice_assignment->contributor_id = $cid;\n        $voice_assignment->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function update_item_transcripts($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n        $transcript = Transcript::find_by_id($id);\n\n        if (!$transcript) {\n            return new \\Podlove\\Api\\Error\\NotFound(\n                'not_found',\n                'transcript with id '.$id.' was not found'\n            );\n        }\n\n        if (isset($request['start'])) {\n            $start = $request['start'];\n            $transcript->start = $start;\n        }\n\n        if (isset($request['end'])) {\n            $end = $request['end'];\n            $transcript->start = $end;\n        }\n\n        if (isset($request['text'])) {\n            $content = $request['text'];\n            $transcript->content = $content;\n        }\n\n        $transcript->save();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_permissions_check($request)\n    {\n        if (!current_user_can('edit_posts')) {\n            return new \\Podlove\\Api\\Error\\ForbiddenAccess();\n        }\n\n        return true;\n    }\n\n    public function delete_item($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($id);\n\n        if (!$episode) {\n            return new \\Podlove\\Api\\Error\\NotFound();\n        }\n\n        Transcript::delete_for_episode($id);\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n\n    public function delete_item_transcripts($request)\n    {\n        $id = $request->get_param('id');\n        if (!$id) {\n            return;\n        }\n        $transcript = Transcript::find_by_id($id);\n\n        if (!$transcript) {\n            return new \\Podlove\\Api\\Error\\NotFound(\n                'not_found',\n                'transcript with id '.$id.' was not found'\n            );\n        }\n\n        $transcript->delete();\n\n        return new \\Podlove\\Api\\Response\\OkResponse([\n            'status' => 'ok'\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/template/group.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Template;\n\nuse Podlove\\Modules\\Contributors;\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Transcript Group Template Wrapper.\n *\n * @templatetag group\n */\nclass Group extends Wrapper\n{\n    private $lines;\n    private $voice;\n    private $contributor_id;\n\n    public function __construct($lines, $contributor_id, $voice)\n    {\n        $this->lines = $lines;\n        $this->contributor_id = $contributor_id;\n        $this->voice = $voice;\n    }\n\n    /**\n     * Items / Lines.\n     *\n     * @accessor\n     */\n    public function items()\n    {\n        return $this->lines;\n    }\n\n    /**\n     * Start time in ms.\n     *\n     * @accessor\n     */\n    public function start()\n    {\n        $first_line = reset($this->lines);\n\n        return $first_line->start();\n    }\n\n    /**\n     * End time in ms.\n     *\n     * @accessor\n     */\n    public function end()\n    {\n        $last_line = end($this->lines);\n\n        return $last_line->end();\n    }\n\n    /**\n     * Voice / Contributor.\n     *\n     * @accessor\n     */\n    public function contributor()\n    {\n        if (!$this->contributor_id) {\n            return null;\n        }\n\n        $contributor = Contributors\\Model\\Contributor::find_by_id($this->contributor_id);\n\n        if (!$contributor) {\n            return null;\n        }\n\n        return new Contributors\\Template\\Contributor($contributor);\n    }\n\n    public function voice()\n    {\n        if (!$this->voice) {\n            return \"Can't find the voice\";\n        }\n\n        return $this->voice;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->lines];\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/template/line.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts\\Template;\n\nuse Podlove\\Template\\Wrapper;\n\n/**\n * Transcript Line Template Wrapper.\n *\n * @templatetag line\n */\nclass Line extends Wrapper\n{\n    private $line;\n\n    public function __construct($line)\n    {\n        $this->line = $line;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Content.\n     *\n     * @accessor\n     */\n    public function content()\n    {\n        return $this->line['text'];\n    }\n\n    /**\n     * Start time in ms.\n     *\n     * @accessor\n     */\n    public function start()\n    {\n        // fixme: this is silly, Duration should take ms as parameter, not a whole episode object\n        $episode = new \\Podlove\\Model\\Episode();\n        $episode->duration = $this->line['start_ms'] / 1000;\n\n        return new \\Podlove\\Template\\Duration($episode);\n    }\n\n    /**\n     * End time in ms.\n     *\n     * @accessor\n     */\n    public function end()\n    {\n        $episode = new \\Podlove\\Model\\Episode();\n        $episode->duration = $this->line['end_ms'] / 1000;\n\n        return new \\Podlove\\Template\\Duration($episode);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->line];\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/template_extensions.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts;\n\nclass TemplateExtensions\n{\n    /**\n     * Transcript, grouped by speaker.\n     *\n     * **Examples**\n     *\n     * ```\n     * <style type=\"text/css\">\n     * .ts-speaker { font-weight: bold; }\n     * .ts-items { margin-left: 20px; }\n     * .ts-time { font-size: small; color: #999; }\n     * </style>\n     * {% for group in episode.transcript %}\n     *   <div class=\"ts-group\">\n     *     {% if group.contributor %}\n     *       <div class=\"ts-speaker\">{{ group.contributor.name }}</div>\n     *     {% endif %}\n     *     <div class=\"ts-items\">\n     *     {% for line in group.items %}\n     *       <span class=\"ts-time\">{{ line.start }}&ndash;{{ line.end }}</span>\n     *       <div class=\"ts-content\">{{ line.content }}</div>\n     *     {% endfor %}\n     *     </div>\n     *   </div>\n     * {% endfor %}\n     * ```\n     *\n     * @accessor\n     *\n     * @dynamicAccessor episode.transcript\n     *\n     * @param mixed $return\n     * @param mixed $method_name\n     */\n    public static function accessorEpisodeTranscript($return, $method_name, \\Podlove\\Model\\Episode $episode)\n    {\n        return $episode->with_blog_scope(function () use ($episode) {\n            $transcript = Model\\Transcript::get_transcript($episode->id);\n            $transcript = Model\\Transcript::prepare_transcript($transcript, 'grouped');\n\n            if (!is_array($transcript)) {\n                return [];\n            }\n\n            return array_map(function ($group) {\n                $lines = array_map(function ($line) {\n                    return new Template\\Line($line);\n                }, $group['items']);\n\n                return new Template\\Group($lines, $group['speaker'] ?? null, $group['voice'] ?? null);\n            }, $transcript);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/transcripts.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Transcripts;\n\nuse Podlove\\Model;\nuse Podlove\\Model\\Episode;\nuse Podlove\\Modules\\Contributors\\Model\\Contributor;\nuse Podlove\\Modules\\Transcripts\\Model\\Transcript;\nuse Podlove\\Modules\\Transcripts\\Model\\VoiceAssignment;\nuse Podlove\\Webvtt\\Parser;\nuse Podlove\\Webvtt\\ParserException;\n\nclass Transcripts extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Transcripts';\n    protected $module_description = 'Manage transcripts, show them on your site and in the web player.';\n    protected $module_group = 'metadata';\n\n    public function load()\n    {\n        add_action('podlove_delete_episode', [$this, 'on_delete_episode']);\n        add_action('podlove_module_was_activated_transcripts', [$this, 'was_activated']);\n        add_filter('podlove_episode_form_data', [$this, 'extend_episode_form'], 10, 2);\n\n        add_filter('mime_types', [$this, 'ensure_vtt_mime_type_is_known'], 20);\n\n        add_filter('podlove_player4_config', [$this, 'add_player_config'], 10, 2);\n        add_filter('podlove_player5_config', [$this, 'add_player_config'], 10, 2);\n\n        add_action('wp', [$this, 'serve_transcript_file']);\n\n        add_action('podlove_xml_export', [$this, 'expandExportFile']);\n        add_filter('podlove_import_jobs', [$this, 'expandImport']);\n\n        // external assets\n        add_action('podlove_asset_assignment_form', [$this, 'add_asset_assignment_form'], 10, 2);\n        add_action('podlove_media_file_content_has_changed', [$this, 'handle_changed_media_file']);\n        add_action('podlove_media_file_content_verified', [$this, 'handle_changed_media_file']);\n\n        add_action('podlove_feeds_global_form', [$this, 'add_feeds_global_form'], 10, 1);\n\n        add_filter('podlove_twig_file_loader', function ($file_loader) {\n            $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'lib', 'modules', 'transcripts', 'twig']), 'transcripts');\n\n            return $file_loader;\n        });\n\n        add_shortcode('podlove-transcript', [$this, 'transcript_shortcode']);\n\n        \\Podlove\\Template\\Episode::add_accessor(\n            'transcript',\n            ['\\Podlove\\Modules\\Transcripts\\TemplateExtensions', 'accessorEpisodeTranscript'],\n            4\n        );\n\n        add_action('rest_api_init', [$this, 'api_init']);\n        add_action('admin_notices', [$this, 'check_contributors_active']);\n\n        $this->add_transcript_to_feed();\n    }\n\n    public function check_contributors_active()\n    {\n        if (!\\Podlove\\Modules\\Base::is_active('contributors')) {\n            $this->print_admin_notice();\n        }\n    }\n\n    public function api_init()\n    {\n        $api = new REST_API();\n        $api->register_routes();\n        $api_v2 = new WP_REST_PodloveTranscripts_Controller();\n        $api_v2->register_routes();\n    }\n\n    public function ensure_vtt_mime_type_is_known($mime_types)\n    {\n        if (!array_key_exists('vtt', $mime_types)) {\n            $mime_types['vtt'] = 'text/vtt';\n        }\n\n        return $mime_types;\n    }\n\n    public function transcript_shortcode($args = [])\n    {\n        if (isset($args['post_id'])) {\n            $post_id = $args['post_id'];\n            unset($args['post_id']);\n        } else {\n            $post_id = get_the_ID();\n        }\n\n        $episode = Model\\Episode::find_one_by_post_id($post_id);\n        $episode = new \\Podlove\\Template\\Episode($episode);\n\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@transcripts/transcript.twig', ['episode' => $episode]);\n    }\n\n    public function uninstall()\n    {\n        Transcript::destroy();\n        VoiceAssignment::destroy();\n    }\n\n    public function was_activated($module_name)\n    {\n        Transcript::build();\n        VoiceAssignment::build();\n    }\n\n    public function on_delete_episode(Episode $episode)\n    {\n        Transcript::delete_for_episode($episode->id);\n    }\n\n    public function extend_episode_form($form_data, $episode)\n    {\n        $form_data[] = [\n            'type' => 'callback',\n            'key' => 'transcripts',\n            'options' => [\n                'callback' => function () {\n                    ?>\n  <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n    <podlove-transcripts></podlove-transcripts>\n  </div>\n<?php\n                }\n            ],\n            'position' => 480,\n        ];\n\n        return $form_data;\n    }\n\n    /**\n     * Import transcript from remote file.\n     */\n    public static function transcript_import_from_asset(Episode $episode)\n    {\n        $asset_assignment = Model\\AssetAssignment::get_instance();\n\n        if (!$transcript_asset = Model\\EpisodeAsset::find_one_by_id($asset_assignment->transcript)) {\n            return [\n                'error' => sprintf(\n                    __('No asset is assigned for transcripts yet. Fix this in %s', 'podlove-podcasting-plugin-for-wordpress'),\n                    sprintf(\n                        '%s%s%s',\n                        '<a href=\"'.admin_url('admin.php?page=podlove_episode_assets_settings_handle').'\" target=\"_blank\">',\n                        __('Episode Assets', 'podlove-podcasting-plugin-for-wordpress'),\n                        '</a>'\n                    )\n                ),\n            ];\n        }\n\n        if (!$transcript_file = Model\\MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $transcript_asset->id)) {\n            return ['error' => __('No transcript file is available for this episode.', 'podlove-podcasting-plugin-for-wordpress')];\n        }\n\n        $transcript = wp_remote_get($transcript_file->get_file_url());\n\n        if (is_wp_error($transcript)) {\n            return ['error' => $transcript->get_error_message()];\n        }\n\n        self::parse_and_import_webvtt($episode, $transcript['body']);\n\n        return true;\n    }\n\n    public static function parse_webvtt($content)\n    {\n        $parser = new Parser();\n\n        try {\n            $result = $parser->parse($content);\n        } catch (ParserException $e) {\n            $error = 'Error parsing webvtt file: '.$e->getMessage();\n            \\Podlove\\Log::get()->addError($error);\n\n            return false;\n        }\n\n        return $result;\n    }\n\n    public static function parse_and_import_webvtt(Episode $episode, $content)\n    {\n        if (function_exists('mb_check_encoding') && !mb_check_encoding($content, 'UTF-8')) {\n            \\Podlove\\AJAX\\Ajax::respond_with_json(['error' => 'Error parsing webvtt file: must be UTF-8 encoded']);\n        }\n\n        $result = self::parse_webvtt($content);\n\n        if ($result === false) {\n            if (isset($_REQUEST['action']) && $_REQUEST['action'] == 'podlove_transcript_import') {\n                \\Podlove\\AJAX\\Ajax::respond_with_json(['error' => 'Error parsing webvtt file']);\n            }\n\n            return;\n        }\n\n        Transcript::delete_for_episode($episode->id);\n\n        foreach ($result['cues'] as $cue) {\n            $line = new Transcript();\n            $line->episode_id = $episode->id;\n            $line->start = $cue['start'] * 1000;\n            $line->end = $cue['end'] * 1000;\n            $line->voice = $cue['voice'];\n            $line->content = $cue['text'];\n            $line->save();\n        }\n\n        $voices = array_unique(array_map(function ($cue) {\n            return $cue['voice'];\n        }, $result['cues']));\n\n        foreach ($voices as $voice) {\n            $contributor = Contributor::find_one_by_property('identifier', $voice);\n\n            if (!VoiceAssignment::is_voice_set($episode->id, $voice) && $contributor) {\n                $voice_assignment = new VoiceAssignment();\n                $voice_assignment->episode_id = $episode->id;\n                $voice_assignment->voice = $voice;\n                $voice_assignment->contributor_id = $contributor->id;\n                $voice_assignment->save();\n            }\n        }\n    }\n\n    public function serve_transcript_file()\n    {\n        $format = filter_input(INPUT_GET, 'podlove_transcript', FILTER_VALIDATE_REGEXP, [\n            'options' => ['regexp' => '/^(json_podcastindex|json_grouped|json|webvtt|xml)$/'],\n        ]);\n\n        if (!$format) {\n            return;\n        }\n\n        $post_id = get_the_ID();\n        if (!$post_id) {\n            $post_id = intval($_GET['p'], 10);\n        }\n\n        if (!$post_id) {\n            return;\n        }\n\n        if (!$episode = Model\\Episode::find_or_create_by_post_id($post_id)) {\n            return;\n        }\n\n        $renderer = new Renderer($episode);\n\n        http_response_code(200);\n\n        switch ($format) {\n            case 'xml':\n                header('Cache-Control: no-cache, must-revalidate');\n                header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n                header('Content-Type: application/xml; charset=utf-8');\n                echo $renderer->as_xml();\n\n                exit;\n\n                break;\n            case 'webvtt':\n                header('Cache-Control: no-cache, must-revalidate');\n                header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n                header('Content-Type: text/vtt');\n                echo $renderer->as_webvtt();\n\n                exit;\n\n                break;\n            case 'json_podcastindex':\n                header('Cache-Control: no-cache, must-revalidate');\n                header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n                header('Content-type: application/json');\n                echo $renderer->as_podcastindex_json();\n\n                exit;\n\n            case 'json':\n            case 'json_grouped':\n                header('Cache-Control: no-cache, must-revalidate');\n                header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n                header('Content-type: application/json');\n                $mode = ($format == 'json' ? 'flat' : 'grouped');\n                echo $renderer->as_json($mode);\n\n                exit;\n\n                break;\n        }\n    }\n\n    public function add_transcript_to_feed()\n    {\n        add_action('podlove_append_to_feed_entry', function ($podcast, $episode, $feed, $format) {\n            $this->print_rss_feed_links($podcast, $episode);\n        }, 10, 4);\n    }\n\n    public function add_player_config($config, $episode)\n    {\n        if (Transcript::exists_for_episode($episode->id)) {\n            $url = add_query_arg('podlove_transcript', 'json', get_permalink($episode->post_id));\n            $url = str_replace(home_url(), site_url(), $url);\n            $config['transcripts'] = $url;\n        }\n\n        return $config;\n    }\n\n    public function add_asset_assignment_form($wrapper, $asset_assignment)\n    {\n        $transcript_options = [\n            'manual' => __('Manual Upload', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        $episode_assets = Model\\EpisodeAsset::all();\n        foreach ($episode_assets as $episode_asset) {\n            $file_type = $episode_asset->file_type();\n            if ($file_type && $file_type->extension === 'vtt') {\n                $transcript_options[$episode_asset->id]\n                = sprintf(__('Asset: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($episode_asset->title));\n            }\n        }\n\n        $wrapper->select('transcript', [\n            'label' => __('Episode Transcript', 'podlove-podcasting-plugin-for-wordpress'),\n            'options' => $transcript_options,\n        ]);\n    }\n\n    public function add_feeds_global_form($wrapper)\n    {\n        $options = [\n            'none' => __('Do not include in feed', 'podlove-podcasting-plugin-for-wordpress'),\n            'generated' => __('Publisher Generated WebVTT (Default)', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        foreach (Model\\EpisodeAsset::all() as $asset) {\n            $file_type = $asset->file_type();\n            if ($file_type && in_array($file_type->extension, ['vtt', 'srt'])) {\n                $options['asset_'.$asset->id] = sprintf(__('Asset: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($asset->title));\n            }\n        }\n\n        $wrapper->select('feed_transcripts', [\n            'label' => __('Episode Transcripts', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('How should episode transcripts be referenced in the RSS feed?', 'podlove-podcasting-plugin-for-wordpress'),\n            'options' => $options,\n            'default' => 'generated',\n        ]);\n    }\n\n    /**\n     * When vtt media file changes, reimport transcripts.\n     *\n     * @param mixed $media_file_id\n     */\n    public function handle_changed_media_file($media_file_id)\n    {\n        $media_file = Model\\MediaFile::find_by_id($media_file_id);\n\n        if (!$media_file) {\n            return;\n        }\n\n        $asset = $media_file->episode_asset();\n\n        if (!$asset) {\n            return;\n        }\n\n        $file_type = $asset->file_type();\n\n        if (!$file_type) {\n            return;\n        }\n\n        if ($file_type->extension !== 'vtt') {\n            return;\n        }\n\n        $this->transcript_import_from_asset($media_file->episode());\n    }\n\n    /**\n     * Expands \"Import/Export\" module: export logic.\n     */\n    public function expandExportFile(\\SimpleXMLElement $xml)\n    {\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'transcripts', 'transcript', '\\Podlove\\Modules\\Transcripts\\Model\\Transcript');\n        \\Podlove\\Modules\\ImportExport\\Export\\PodcastExporter::exportTable($xml, 'voice_assignments', 'voice_assignment', '\\Podlove\\Modules\\Transcripts\\Model\\VoiceAssignment');\n    }\n\n    /**\n     * Expands \"Import/Export\" module: import logic.\n     *\n     * @param mixed $jobs\n     */\n    public function expandImport($jobs)\n    {\n        $jobs[] = '\\Podlove\\Modules\\Transcripts\\Jobs\\ImportTranscriptsJob';\n        $jobs[] = '\\Podlove\\Modules\\Transcripts\\Jobs\\ImportVoiceAssignmentsJob';\n\n        return $jobs;\n    }\n\n    private function print_rss_feed_links($podcast, $episode)\n    {\n        if ($podcast->feed_transcripts == 'none') {\n            return;\n        }\n        if ($podcast->feed_transcripts == 'generated') {\n            if (!Transcript::exists_for_episode($episode->id)) {\n                return;\n            }\n\n            $permalink = get_permalink($episode->post_id);\n            $permalink = str_replace(home_url(), site_url(), $permalink);\n\n            $url = add_query_arg('podlove_transcript', 'webvtt', $permalink);\n            echo \"\\n\\t\\t\".'<podcast:transcript url=\"'.esc_attr($url).'\" type=\"text/vtt\" />';\n\n            $url = add_query_arg('podlove_transcript', 'json_podcastindex', $permalink);\n            echo \"\\n\\t\\t\".'<podcast:transcript url=\"'.esc_attr($url).'\" type=\"application/json\" />';\n\n            return;\n        }\n        if (preg_match('/^asset_(?<id>\\d+)$/', $podcast->feed_transcripts, $matches) === 1) {\n            $asset_id = $matches['id'];\n            $asset = Model\\EpisodeAsset::find_by_id($asset_id);\n\n            if (!$asset) {\n                return;\n            }\n\n            $file = Model\\MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset->id);\n\n            if (!$file || !$file->active) {\n                return;\n            }\n\n            $file_type = $asset->file_type();\n\n            $url = $file->get_file_url();\n            echo \"\\n\\t\\t\".'<podcast:transcript url=\"'.esc_attr($url).'\" type=\"'.esc_attr($file_type->mime_type).'\" />';\n        }\n\n        // $url = add_query_arg('podlove_transcript', 'xml', get_permalink($episode->post_id));\n        // $url = str_replace(home_url(), site_url(), $url);\n        // echo \"\\n\\t\\t\".'<podcast:transcript url=\"'.esc_attr($url).'\" type=\"application/xml\" />';\n    }\n\n    private function print_admin_notice()\n    {\n        ?>\n      <div class=\"update-message notice notice-warning notice-alt\">\n        <p>\n          <?php echo __('You need to activate the \"Contributors\" module to use transcripts.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n           <a href=\"<?php echo admin_url('admin.php?page=podlove_settings_modules_handle#contributors'); ?>\"><?php echo __('Activate Now', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n        </p>\n      </div>\n      <?php\n    }\n}\n"
  },
  {
    "path": "lib/modules/transcripts/twig/transcript.twig",
    "content": "<style type=\"text/css\">\n.ts-group { clear: both; margin-top: 15px; }\n.ts-speaker-avatar { margin-top: 5px; float: left; }\n.ts-speaker-avatar img { border-radius: 10%; }\n.ts-speaker { font-weight: bold; font-size: 90%; }\n.ts-items { margin-left: 20px; }\n.ts-time { font-size: small; color: #999; }\n.ts-text { margin-left: 60px; }\n.ts-line:hover { background-color: #f9f9f9; }\n</style>\n\n<hr style=\"clear: both;\" />\n\n{% for group in episode.transcript %}\n    <div class=\"ts-group\">\n\n        <div class=\"ts-speaker-avatar\">\n            {{ group.contributor.image.html({width: 50}) }}\n        </div>\n\n        <div class=\"ts-text\">\n            <div class=\"ts-speaker\">\n                {{ group.contributor.name }}\n            </div>\n\n            <div class=\"ts-content\">\n                {% for line in group.items %}\n                <span class=\"ts-line\">{{ line.content }}</span>\n                {% endfor %}\n            </div>\n        </div>\n        \n    </div>\n{% endfor %}\n"
  },
  {
    "path": "lib/modules/widgets/widgets/podcast_information.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Widgets\\Widgets;\n\nclass PodcastInformation extends \\WP_Widget\n{\n    public function __construct()\n    {\n        parent::__construct(\n            'podlove_podcast_widget',\n            __('Podcast Information', 'podlove-podcasting-plugin-for-wordpress'),\n            ['description' => __('Displays basic information about your Podcast.', 'podlove-podcasting-plugin-for-wordpress')]\n        );\n    }\n\n    public function widget($args, $instance)\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        echo $args['before_widget'];\n\n        echo $args['before_title'].apply_filters('widget_title', (empty($instance['title'])) ? $podcast->title : $instance['title']).$args['after_title'];\n\n        if ($instance['show_image']) {\n            echo $podcast->cover_art()->setWidth(400)->image(['alt' => $podcast->title]);\n        }\n\n        if ($instance['show_subtitle']) {\n            echo '<p><strong>'.$podcast->subtitle.'</strong></p>';\n        }\n\n        if ($instance['show_summary']) {\n            echo wpautop($podcast->summary);\n        }\n\n        echo $args['after_widget'];\n    }\n\n    public function form($instance)\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $title = isset($instance['title']) ? $instance['title'] : '';\n        $show_image = isset($instance['show_image']) ? $instance['show_image'] : '';\n        $show_subtitle = isset($instance['show_subtitle']) ? $instance['show_subtitle'] : '';\n        $show_summary = isset($instance['show_summary']) ? $instance['show_summary'] : ''; ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('title'); ?>\"><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" id=\"<?php echo $this->get_field_id('title'); ?>\" name=\"<?php echo $this->get_field_name('title'); ?>\" value=\"<?php echo $title; ?>\" placeholder=\"<?php echo $podcast->title; ?>\" />\n\t\t</p>\n\t\t<p>\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('show_image'); ?>\" name=\"<?php echo $this->get_field_name('show_image'); ?>\" <?php echo $show_image ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('show_image'); ?>\"><?php _e('Display image', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('show_subtitle'); ?>\" name=\"<?php echo $this->get_field_name('show_subtitle'); ?>\" <?php echo $show_subtitle ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('show_subtitle'); ?>\"><?php _e('Display subtitle', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('show_summary'); ?>\" name=\"<?php echo $this->get_field_name('show_summary'); ?>\" <?php echo $show_summary ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('show_summary'); ?>\"><?php _e('Display summary', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\t\t</p>\n\t\t<?php\n    }\n\n    public function update($new_instance, $old_instance)\n    {\n        $instance = [];\n        $instance['title'] = (!empty($new_instance['title'])) ? wp_strip_all_tags($new_instance['title']) : '';\n        $instance['show_image'] = (!empty($new_instance['show_image'])) ? wp_strip_all_tags($new_instance['show_image']) : '';\n        $instance['show_subtitle'] = (!empty($new_instance['show_subtitle'])) ? wp_strip_all_tags($new_instance['show_subtitle']) : '';\n        $instance['show_summary'] = (!empty($new_instance['show_summary'])) ? wp_strip_all_tags($new_instance['show_summary']) : '';\n\n        return $instance;\n    }\n}\n"
  },
  {
    "path": "lib/modules/widgets/widgets/podcast_license.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Widgets\\Widgets;\n\nclass PodcastLicense extends \\WP_Widget\n{\n    public function __construct()\n    {\n        parent::__construct(\n            'podlove_podcast_license_widget',\n            __('Podcast License', 'podlove-podcasting-plugin-for-wordpress'),\n            ['description' => __('Displays the license of your podcast.', 'podlove-podcasting-plugin-for-wordpress')]\n        );\n    }\n\n    public function widget($args, $instance)\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        echo $args['before_widget'];\n\n        if (!empty($instance['title'])) {\n            echo $args['before_title'].apply_filters('widget_title', $instance['title']).$args['after_title'];\n        }\n\n        echo $podcast->get_license_html();\n\n        echo $args['after_widget'];\n    }\n\n    public function form($instance)\n    {\n        $title = isset($instance['title']) ? $instance['title'] : ''; ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('title'); ?>\"><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" id=\"<?php echo $this->get_field_id('title'); ?>\" name=\"<?php echo $this->get_field_name('title'); ?>\" value=\"<?php echo $title; ?>\" />\n\t\t</p>\n\t\t<?php\n    }\n\n    public function update($new_instance, $old_instance)\n    {\n        $instance = [];\n        $instance['title'] = (!empty($new_instance['title'])) ? wp_strip_all_tags($new_instance['title']) : '';\n\n        return $instance;\n    }\n}\n"
  },
  {
    "path": "lib/modules/widgets/widgets/recent_episodes.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Widgets\\Widgets;\n\nuse Podlove\\Model\\Episode;\n\nclass RecentEpisodes extends \\WP_Widget\n{\n    public function __construct()\n    {\n        parent::__construct(\n            'podlove_recent_episodes_widget',\n            __('Recent Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            ['description' => __('Shows the recent episodes of your podcast.', 'podlove-podcasting-plugin-for-wordpress')]\n        );\n    }\n\n    public function widget($args, $instance)\n    {\n        $number_of_episodes = (is_numeric($instance['number_of_episodes']) ? $instance['number_of_episodes'] : 10); // Fallback for old browsers that allow a non-numeric string to be entered in the \"number_of_episodes\" field\n        $episodes = array_slice(\n            Episode::find_all_by_time(['post_status' => ['private', 'publish']]),\n            0,\n            $number_of_episodes\n        );\n\n        echo $args['before_widget'];\n\n        if (!empty($instance['title'])) {\n            echo $args['before_title'].apply_filters('widget_title', $instance['title']).$args['after_title'];\n        }\n\n        echo \"<ul style='list-style-type: none;'>\";\n        foreach ($episodes as $episode) {\n            $post = get_post($episode->post_id);\n            $episode_duration = new \\Podlove\\Duration($episode->duration); ?>\n\t\t\t\t<li>\n\t\t\t\t\t<?php if ($instance['show_image']) { ?>\n\t\t\t\t\t<img src=\"<?php echo $episode->cover_art_with_fallback()->setWidth(400)->url(); ?>\" alt=\"<?php echo $post->post_title; ?>\" style=\"width: 20%; vertical-align: top; margin-right: 2%;\"/>\n\t\t\t\t\t<div style=\"display: inline-block; width: 75%;\">\n\t\t\t\t\t<?php } ?>\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<a href=\"<?php echo get_permalink($episode->post_id); ?>\"><?php echo $post->post_title; ?></a><br />\n\t\t\t\t\t\t<i class=\"podlove-icon-calendar\"></i> <?php echo get_the_date(get_option('date_format'), $episode->post_id); ?>\n\t\t\t\t\t\t<?php\n                        if ($instance['show_duration']) {\n                            echo \"<br /><i class='podlove-icon-time'></i> \".$episode_duration->get('human-readable');\n                        } ?>\n\t\t\t\t\t</p>\n\t\t\t\t\t<?php if ($instance['show_image']) { ?>\n\t\t\t\t\t</div>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</li>\n\t\t\t<?php\n        }\n        echo '</ul>';\n\n        echo $args['after_widget'];\n    }\n\n    public function form($instance)\n    {\n        $title = isset($instance['title']) ? $instance['title'] : '';\n        $number_of_episodes = isset($instance['number_of_episodes']) ? $instance['number_of_episodes'] : '';\n        $show_image = isset($instance['show_image']) ? $instance['show_image'] : '';\n        $show_duration = isset($instance['show_duration']) ? $instance['show_duration'] : ''; ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('title'); ?>\"><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" id=\"<?php echo $this->get_field_id('title'); ?>\" name=\"<?php echo $this->get_field_name('title'); ?>\" value=\"<?php echo $title; ?>\" />\n\t\t</p>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('number_of_episodes'); ?>\"><?php _e('Number of Episodes', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" type=\"number\" id=\"<?php echo $this->get_field_id('number_of_episodes'); ?>\" name=\"<?php echo $this->get_field_name('number_of_episodes'); ?>\" value=\"<?php echo $number_of_episodes; ?>\" />\n\t\t</p>\n\t\t<p>\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('show_image'); ?>\" name=\"<?php echo $this->get_field_name('show_image'); ?>\" <?php echo $show_image ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('show_image'); ?>\"><?php _e('Display episode image', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('show_duration'); ?>\" name=\"<?php echo $this->get_field_name('show_duration'); ?>\" <?php echo $show_duration ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('show_duration'); ?>\"><?php _e('Show duration', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\t\t</p>\n\t\t<?php\n    }\n\n    public function update($new_instance, $old_instance)\n    {\n        $instance = [];\n        $instance['title'] = (!empty($new_instance['title'])) ? wp_strip_all_tags($new_instance['title']) : '';\n        $instance['number_of_episodes'] = (!empty($new_instance['number_of_episodes'])) ? wp_strip_all_tags($new_instance['number_of_episodes']) : '';\n        $instance['show_image'] = (!empty($new_instance['show_image'])) ? wp_strip_all_tags($new_instance['show_image']) : '';\n        $instance['show_duration'] = (!empty($new_instance['show_duration'])) ? wp_strip_all_tags($new_instance['show_duration']) : '';\n\n        return $instance;\n    }\n}\n"
  },
  {
    "path": "lib/modules/widgets/widgets/render_template.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Widgets\\Widgets;\n\nclass RenderTemplate extends \\WP_Widget\n{\n    public function __construct()\n    {\n        parent::__construct(\n            'podlove_render_template_widget',\n            __('Podlove Template', 'podlove-podcasting-plugin-for-wordpress'),\n            ['description' => __('Renders a Podlove template.', 'podlove-podcasting-plugin-for-wordpress')]\n        );\n    }\n\n    public function widget($args, $instance)\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        echo $args['before_widget'];\n\n        if (!empty($instance['title'])) {\n            echo $args['before_title'].apply_filters('widget_title', $instance['title']).$args['after_title'];\n        }\n\n        echo do_shortcode('[podlove-template template=\"'.$instance['template'].'\" autop=\"'.($instance['autop'] ? 'yes' : 'no').'\"]');\n\n        echo $args['after_widget'];\n    }\n\n    public function form($instance)\n    {\n        $templates = \\Podlove\\Model\\Template::all_globally();\n        $title = isset($instance['title']) ? $instance['title'] : '';\n        $selected_template = isset($instance['template']) ? $instance['template'] : '';\n        $autop = isset($instance['autop']) ? $instance['autop'] : ''; ?>\n\t\t<p>\n\t\t\t<label for=\"<?php echo $this->get_field_id('title'); ?>\"><?php _e('Title', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<input class=\"widefat\" id=\"<?php echo $this->get_field_id('title'); ?>\" name=\"<?php echo $this->get_field_name('title'); ?>\" value=\"<?php echo $title; ?>\" />\n\n\t\t\t<label for=\"<?php echo $this->get_field_id('template'); ?>\"><?php _e('Template', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t<select class=\"widefat\" id=\"<?php echo $this->get_field_id('template'); ?>\" name=\"<?php echo $this->get_field_name('template'); ?>\">\n\t\t\t\t<?php\n                    foreach ($templates as $template) {\n                        ?>\n\t\t\t\t\t\t<option value=\"<?php echo $template->title; ?>\" <?php echo $selected_template == $template->title ? 'selected=\\\"selected\\\"' : ''; ?>><?php echo $template->title; ?></option>\n\t\t\t\t\t\t<?php\n                    } ?>\n\t\t\t</select>\n\n\t\t\t<input class=\"widefat\" type=\"checkbox\" id=\"<?php echo $this->get_field_id('autop'); ?>\" name=\"<?php echo $this->get_field_name('autop'); ?>\" <?php echo $autop ? 'checked=\"checked\"' : ''; ?> />\n\t\t\t<label for=\"<?php echo $this->get_field_id('autop'); ?>\"><?php _e('Autowrap blocks of text?', 'podlove-podcasting-plugin-for-wordpress'); ?></label><br />\n\t\t</p>\n\t\t<?php\n    }\n\n    public function update($new_instance, $old_instance)\n    {\n        $instance = [];\n        $instance['title'] = (!empty($new_instance['title'])) ? wp_strip_all_tags($new_instance['title']) : '';\n        $instance['template'] = (!empty($new_instance['template'])) ? wp_strip_all_tags($new_instance['template']) : '';\n        $instance['autop'] = (!empty($new_instance['autop'])) ? wp_strip_all_tags($new_instance['autop']) : '';\n\n        return $instance;\n    }\n}\n"
  },
  {
    "path": "lib/modules/widgets/widgets.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\Widgets;\n\nclass Widgets extends \\Podlove\\Modules\\Base\n{\n    protected $module_name = 'Widgets';\n    protected $module_description = 'Brings a bunch of useful Podlove Publisher widgets to WordPress.';\n    protected $module_group = 'web publishing';\n\n    public static function is_core()\n    {\n        return true;\n    }\n\n    public function load()\n    {\n        $widgets = [\n            '\\Podlove\\Modules\\Widgets\\Widgets\\PodcastLicense',\n            '\\Podlove\\Modules\\Widgets\\Widgets\\RecentEpisodes',\n            '\\Podlove\\Modules\\Widgets\\Widgets\\PodcastInformation',\n            '\\Podlove\\Modules\\Widgets\\Widgets\\RenderTemplate',\n        ];\n        $widgets = apply_filters('podlove_widgets', $widgets);\n\n        foreach ($widgets as $widget_class) {\n            add_action('widgets_init', function () use ($widget_class) {\n                register_widget($widget_class);\n            });\n        }\n    }\n}\n"
  },
  {
    "path": "lib/modules/wordpress_file_upload/wordpress_file_upload.php",
    "content": "<?php\n\nnamespace Podlove\\Modules\\WordpressFileUpload;\n\nclass Wordpress_File_Upload extends \\Podlove\\Modules\\Base\n{\n    public const DEFAULT_DIR = '/podlove-media';\n\n    protected $module_name = 'WordPress File Upload';\n    protected $module_description = 'If you want to upload your media files to WordPress, this module adds a button to the episode form to do that.';\n    protected $module_group = 'system';\n\n    public function load()\n    {\n        add_action('init', [$this, 'register_public_hooks']);\n        add_action('admin_init', [$this, 'register_hooks']);\n        // FIXME: this is huge. admin_init is not run for REST calls? what else might this affect?\n        add_action('rest_api_init', [$this, 'register_hooks']);\n\n        add_action('init', fn () => $this->register_option('upload_subdir', 'string', [\n            'label' => __('Upload subdirectory', 'podlove-podcasting-plugin-for-wordpress'),\n            'description' => __('Directory relative to WordPress upload directory where files will be stored.', 'podlove-podcasting-plugin-for-wordpress'),\n            'html' => [\n                'class' => 'regular-text podlove-check-input',\n                'placeholder' => self::DEFAULT_DIR\n            ],\n        ]));\n\n        $podlove_subdir = trim($this->get_module_option('upload_subdir') ?? '');\n        if (!$podlove_subdir) {\n            add_action('admin_notices', function () {\n                ?>\n                <div id=\"message\" class=\"notice notice-success\">\n                    <p>\n                        <strong><?php echo sprintf(\n                            __('Module \"%s\" is active.', 'podlove-podcasting-plugin-for-wordpress'),\n                            $this->module_name\n                        ); ?></strong>\n                    </p>\n                    <p>\n                        <?php echo __('You need to configure the subdirectory in the WordPress upload directory where your media files should be stored.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                    </p>\n                    <p>\n                        <a href=\"<?php echo admin_url('admin.php?page=podlove_settings_modules_handle#wordpress_file_upload'); ?>\">\n                          <?php echo __('Go to module settings', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                        </a>\n                    </p>\n                </div>\n                <?php\n            });\n        }\n    }\n\n    public function register_public_hooks()\n    {\n        add_filter('podlove_media_file_base_uri', [$this, 'set_media_file_base_uri']);\n    }\n\n    public function register_hooks()\n    {\n        add_filter('upload_dir', [$this, 'custom_media_upload_dir']);\n        add_filter('podlove_media_file_base_uri_form', [$this, 'set_form_placeholder']);\n    }\n\n    public function set_media_file_base_uri($uri)\n    {\n        // TODO: UX wise it is very confusing that the media_file_base_uri must\n        // be empty for this to work. But there are usecases (Proxy/CDN) where\n        // it's needed that cannot be ignored.\n        if (trim($uri, ' /') === '') {\n            $upload_dir = wp_upload_dir();\n            $upload_dir = $this->custom_media_upload_dir($upload_dir, true);\n\n            return trailingslashit($upload_dir['url']);\n        }\n\n        return $uri;\n    }\n\n    public function set_form_placeholder($config)\n    {\n        $upload_dir = wp_upload_dir();\n        $upload_dir = $this->custom_media_upload_dir($upload_dir, true);\n\n        $config['html']['placeholder'] = $upload_dir['url'];\n\n        return $config;\n    }\n\n    /**\n     * Override upload_dir so it ignores date subdirectories etc.\n     *\n     * @param mixed $upload\n     * @param mixed $force_override\n     */\n    public function custom_media_upload_dir($upload, $force_override = false)\n    {\n        $podlove_subdir = $this->get_subdir();\n\n        $id = isset($_REQUEST['post_id']) ? (int) $_REQUEST['post_id'] : 0;\n\n        if (isset($_REQUEST['post'])) {\n            // when uploaded via POST /wp/v2/media\n            $parent = (int) $_REQUEST['post'];\n        } else {\n            $parent = $id ? get_post($id)->post_parent : 0;\n        }\n\n        if ($force_override || 'podcast' == get_post_type($id) || 'podcast' == get_post_type($parent)) {\n            $upload['subdir'] = $podlove_subdir;\n        }\n\n        $upload['path'] = $upload['basedir'].$upload['subdir'];\n        $upload['url'] = $upload['baseurl'].$upload['subdir'];\n\n        return $upload;\n    }\n\n    private function get_subdir()\n    {\n        $dir = trim($this->get_module_option('upload_subdir'));\n\n        if (empty($dir)) {\n            $dir = self::DEFAULT_DIR;\n        }\n\n        if ($dir[0] !== '/') {\n            $dir = '/'.$dir;\n        }\n\n        return $dir;\n    }\n}\n"
  },
  {
    "path": "lib/network.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Execute callback function for every blog in network with active Publisher plugin.\n *\n * Switches blog scope using `switch_to_blog()`.\n *\n * @param callable $callback\n */\nfunction for_every_podcast_blog($callback)\n{\n    global $wpdb;\n\n    $plugin = basename(\\Podlove\\PLUGIN_DIR).'/'.\\Podlove\\PLUGIN_FILE_NAME;\n    $blogids = $wpdb->get_col('SELECT blog_id FROM '.$wpdb->blogs);\n\n    if (!is_array($blogids)) {\n        return;\n    }\n\n    foreach ($blogids as $blog_id) {\n        switch_to_blog($blog_id);\n        if (is_plugin_active($plugin)) {\n            $callback();\n        }\n        restore_current_blog();\n    }\n}\n"
  },
  {
    "path": "lib/php/array.php",
    "content": "<?php\n\nnamespace Podlove\\PHP;\n\n/**\n * @param array      $array\n * @param int|string $position\n * @param mixed      $insert\n */\nfunction array_insert($array, $position, $insert)\n{\n    if (is_int($position)) {\n        return array_splice($array, $position, 0, $insert);\n    }\n    $pos = array_search($position, array_keys($array));\n\n    return array_merge(\n        array_slice($array, 0, $pos),\n        $insert,\n        array_slice($array, $pos)\n    );\n}\n"
  },
  {
    "path": "lib/php/string.php",
    "content": "<?php\n\nnamespace Podlove\\PHP;\n\n/**\n * strpos wrapper that prefers mb_strpos but falls back to strpos.\n *\n * @param mixed $haystack\n * @param mixed $needle\n * @param mixed $offset\n * @param mixed $encoding\n */\nfunction strpos($haystack, $needle, $offset = 0, $encoding = 'UTF-8')\n{\n    if (function_exists('mb_strpos')) {\n        return mb_strpos($haystack, $needle, $offset, $encoding);\n    }\n\n    return strpos($haystack, $needle, $offset);\n}\n\n/**\n * strlen wrapper that prefers mb_strlen but falls back to strlen.\n *\n * @param mixed $str\n * @param mixed $encoding\n */\nfunction strlen($str, $encoding = 'UTF-8')\n{\n    if (function_exists('mb_strlen')) {\n        return mb_strlen($str, $encoding);\n    }\n\n    return strlen($str);\n}\n\n/**\n * substr wrapper that prefers mb_substr but falls back to substr.\n *\n * @param mixed      $str\n * @param mixed      $start\n * @param null|mixed $length\n * @param mixed      $encoding\n */\nfunction substr($str, $start, $length = null, $encoding = 'UTF-8')\n{\n    if (function_exists('mb_substr')) {\n        return mb_substr($str, $start, $length, $encoding);\n    }\n\n    return substr($str, $start, $length);\n}\n\n/**\n * Check string ends with a certain character or substring.\n *\n * @param string $haystack String to search\n * @param string $needle   Substring or character\n *\n * @return bool\n */\nfunction ends_with($haystack, $needle)\n{\n    return $needle === substr($haystack, -strlen($needle));\n}\n\n/**\n * Escape WordPress Shortcodes.\n *\n * Replaces the opening square bracket by its HTML code, which is\n * ignored by WordPress.\n *\n * @param string $text\n *\n * @return string\n */\nfunction escape_shortcodes($text)\n{\n    if ($text == null) {\n        return '';\n    }\n\n    return preg_replace_callback('/'.get_shortcode_regex().'/', function ($matches) {\n        return str_replace('[', '&#x005B;', $matches[0]);\n    }, $text);\n}\n\nfunction hex2str($hex)\n{\n    return pack('H*', $hex);\n}\n\nfunction str2hex($str)\n{\n    $tmp = unpack('H*', $str);\n\n    return array_shift($tmp);\n}\n"
  },
  {
    "path": "lib/php_deprecation_warning.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass PhpDeprecationWarning\n{\n    public static $target_version = '5.4';\n\n    public static function init()\n    {\n        $correct_php_version = version_compare(phpversion(), self::$target_version, '>=');\n\n        if ($correct_php_version) {\n            return;\n        }\n\n        // don't show on non-podlove pages\n        if (!isset($_GET['page']) || false === stristr($_GET['page'], 'podlove')) {\n            return;\n        }\n\n        add_action('admin_notices', [__CLASS__, 'show_warning']);\n    }\n\n    public static function show_warning()\n    {\n        ?>\n\t\t<div id=\"message\" class=\"error\">\n\t\t\t<p>\n\t\t\t\t<strong><?php echo __('Please upgrade your PHP as soon as possible.', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('You are running PHP %s, which is deprecated.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t    phpversion()\n\t\t\t\t); ?>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('Read %sour blogpost%s for further details.'),\n\t\t\t\t    '<a target=\"_blank\" href=\"http://podlove.org/2014/08/14/podlove-publisher-2-phasing-out-php-5-3/\">',\n\t\t\t\t    '</a>'\n\t\t\t\t); ?>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('As long as you opt to not upgrade, do not attempt to update to Podlove Publisher 2.0 and above.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/podcast_post_meta_box.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Meta Box for Podcase Settings in Post Edit Screen.\n */\nclass Podcast_Post_Meta_Box\n{\n    private static $nonce = 'update_episode_meta';\n\n    public function __construct()\n    {\n        add_action('save_post', [$this, 'save_postdata'], 10, 2);\n        add_action('save_post_podcast', function ($post_id, $post, $_) {\n            if ($episode = Model\\Episode::find_one_by_where('post_id = '.intval($post_id))) {\n                do_action('podlove_episode_content_has_changed', $episode->id);\n            }\n        }, 10, 3);\n    }\n\n    public static function add_meta_box()\n    {\n        add_meta_box(\n            // $id\n            'podlove_podcast',\n            // $title\n            __('Podcast Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            '\\Podlove\\Podcast_Post_Meta_Box::post_type_meta_box_callback',\n            // $page\n            'podcast',\n            // $context\n            'normal',\n            // $priority\n            'high'\n        );\n    }\n\n    /**\n     * Meta Box Template.\n     *\n     * @param mixed $post\n     */\n    public static function post_type_meta_box_callback($post)\n    {\n        $episode = Model\\Episode::find_or_create_by_post_id($post->ID);\n        ?>\n\n\t\t<?php do_action('podlove_episode_meta_box_start'); ?>\n\n\t\t<div class=\"podlove-div-wrapper-form\">\n\t\t\t<?php\n            $form_args = [\n                'context' => '_podlove_meta',\n                'submit_button' => false,\n                'form' => false,\n                'is_table' => false,\n                'nonce' => self::$nonce\n            ];\n\n        $form_data = self::get_form_data($episode);\n\n        \\Podlove\\Form\\build_for($episode, $form_args, function ($form) use ($form_data) {\n            $wrapper = new \\Podlove\\Form\\Input\\DivWrapper($form);\n\n            foreach ($form_data as $entry) {\n                $wrapper->{$entry['type']}($entry['key'], $entry['options']);\n            }\n        }); ?>\n\t\t</div>\n\n\t\t<?php do_action('podlove_episode_meta_box_end'); ?>\n\n\t\t<?php\n    }\n\n    public static function compare_by_position($a, $b)\n    {\n        $pos_a = isset($a['position']) ? (int) $a['position'] : 0;\n        $pos_b = isset($b['position']) ? (int) $b['position'] : 0;\n\n        if ($a == $b || $pos_a == $pos_b) {\n            return 0;\n        }\n\n        return ($pos_a < $pos_b) ? 1 : -1;\n    }\n\n    /**\n     * Save post data on WordPress callback.\n     *\n     * @param int   $post_id\n     * @param mixed $post\n     */\n    public function save_postdata($post_id, $post)\n    {\n        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {\n            return;\n        }\n\n        // Check permissions\n        if ('podcast' !== $post->post_type || !current_user_can('edit_post', $post_id)) {\n            return;\n        }\n\n        if (!isset($_POST['_podlove_meta']) || !is_array($_POST['_podlove_meta'])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        do_action('podlove_save_episode', $post_id, $_POST['_podlove_meta']);\n\n        // sanitize data\n        $episode_data = filter_input_array(INPUT_POST, [\n            '_podlove_meta' => ['flags' => FILTER_REQUIRE_ARRAY],\n        ]);\n        $episode_data = $episode_data['_podlove_meta'];\n\n        // TODO: when we migrate the guid component we can remove most of this\n        // (including the _podlove_meta stuff above)\n        // BUT we should keep the hooks for compatibility.\n        $episode_data_filter = [\n            'guid' => FILTER_UNSAFE_RAW,\n        ];\n        $episode_data_filter = apply_filters('podlove_episode_data_filter', $episode_data_filter);\n        $episode_data = filter_var_array($episode_data, $episode_data_filter);\n        $episode_data = apply_filters('podlove_episode_data_before_save', $episode_data);\n\n        // save changes\n        $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post_id);\n        $episode->update_attributes($episode_data);\n    }\n\n    private static function get_form_data($episode)\n    {\n        $form_data = [\n            [\n                'type' => 'callback',\n                'key' => 'episode_assets',\n                'options' => [\n                    'callback' => function () {\n                        ?>\n                    <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n                      <podlove-media-files></podlove-media-files>\n                    </div>\n                  <?php\n                    }\n                ],\n                'position' => 600,\n            ], [\n                'type' => 'callback',\n                'key' => 'descriptions',\n                'options' => [\n                    'callback' => function () {\n                        ?>\n                    <div data-client=\"podlove\" style=\"margin: 15px 0;\">\n                      <podlove-description></podlove-description>\n                    </div>\n                  <?php\n                    }\n                ],\n                'position' => 900,\n            ]\n        ];\n\n        // allow modules to add / change the form\n        $form_data = apply_filters('podlove_episode_form_data', $form_data, $episode);\n\n        // sort entities by position\n        // TODO first sanitize position attribute, then I don't have to check on each comparison\n        usort($form_data, [__CLASS__, 'compare_by_position']);\n\n        return $form_data;\n    }\n}\n"
  },
  {
    "path": "lib/podcast_post_type.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Model\\Episode;\n\n/**\n * Custom Post Type: \"podcast\".\n */\nclass Podcast_Post_Type\n{\n    public const SETTINGS_PAGE_HANDLE = 'podlove_settings_handle';\n    public const NETWORK_SETTINGS_PAGE_HANDLE = 'podlove_network_settings_handle';\n\n    public function __construct()\n    {\n        $labels = [\n            'name' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'singular_name' => __('Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'add_new' => __('Add New', 'podlove-podcasting-plugin-for-wordpress'),\n            'add_new_item' => __('Add New Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'edit_item' => __('Edit Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'new_item' => __('New Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'all_items' => __('All Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'view_item' => __('View Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            'search_items' => __('Search Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n            'not_found' => __('No episodes found', 'podlove-podcasting-plugin-for-wordpress'),\n            'not_found_in_trash' => __('No episodes found in Trash', 'podlove-podcasting-plugin-for-wordpress'),\n            'parent_item_colon' => '',\n            'menu_name' => __('Episodes', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        $args = [\n            'labels' => $labels,\n            'public' => true,\n            'publicly_queryable' => true,\n            'show_ui' => true,\n            'show_in_menu' => true,\n            'menu_position' => 5, // below \"Posts\"\n            'query_var' => true,\n            'rewrite' => [\n                'slug' => trim(\\Podlove\\get_setting('website', 'episode_archive_slug'), '/'),\n            ],\n            'has_archive' => 'on' == \\Podlove\\get_setting('website', 'episode_archive'),\n            'capability_type' => 'post',\n            'supports' => ['title', 'editor', 'author', 'thumbnail', 'comments', 'revisions', 'custom-fields', 'trackbacks'],\n            'register_meta_box_cb' => '\\Podlove\\Podcast_Post_Meta_Box::add_meta_box',\n            'menu_icon' => 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjQsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCIgWw0KCTwhRU5USVRZIG5zX2V4dGVuZCAiaHR0cDovL25zLmFkb2JlLmNvbS9FeHRlbnNpYmlsaXR5LzEuMC8iPg0KCTwhRU5USVRZIG5zX2FpICJodHRwOi8vbnMuYWRvYmUuY29tL0Fkb2JlSWxsdXN0cmF0b3IvMTAuMC8iPg0KCTwhRU5USVRZIG5zX2dyYXBocyAiaHR0cDovL25zLmFkb2JlLmNvbS9HcmFwaHMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfdmFycyAiaHR0cDovL25zLmFkb2JlLmNvbS9WYXJpYWJsZXMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfaW1yZXAgImh0dHA6Ly9ucy5hZG9iZS5jb20vSW1hZ2VSZXBsYWNlbWVudC8xLjAvIj4NCgk8IUVOVElUWSBuc19zZncgImh0dHA6Ly9ucy5hZG9iZS5jb20vU2F2ZUZvcldlYi8xLjAvIj4NCgk8IUVOVElUWSBuc19jdXN0b20gImh0dHA6Ly9ucy5hZG9iZS5jb20vR2VuZXJpY0N1c3RvbU5hbWVzcGFjZS8xLjAvIj4NCgk8IUVOVElUWSBuc19hZG9iZV94cGF0aCAiaHR0cDovL25zLmFkb2JlLmNvbS9YUGF0aC8xLjAvIj4NCl0+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zOng9IiZuc19leHRlbmQ7IiB4bWxuczppPSImbnNfYWk7IiB4bWxuczpncmFwaD0iJm5zX2dyYXBoczsiDQoJIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiDQoJIHZpZXdCb3g9IjAgMCAxMjggMTI4IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxtZXRhZGF0YT4NCgk8c2Z3ICB4bWxucz0iJm5zX3NmdzsiPg0KCQk8c2xpY2VzPjwvc2xpY2VzPg0KCQk8c2xpY2VTb3VyY2VCb3VuZHMgIGhlaWdodD0iMTIzLjEiIHdpZHRoPSIxMjcuODg5IiBib3R0b21MZWZ0T3JpZ2luPSJ0cnVlIiB4PSItMC40NDQiIHk9IjMuNCI+PC9zbGljZVNvdXJjZUJvdW5kcz4NCgk8L3Nmdz4NCjwvbWV0YWRhdGE+DQo8Zz4NCgk8Zz4NCgkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTM0LjczNyw1NS43MzdjMC0xLjU1My0wLjIyNC0zLjA1MS0wLjYwNy00LjQ4NGw5MC4wNDYtMjQuMTI5TDExNy40NDUsMkwxMC42NjUsMzAuNjEzbDIuMjQ4LDguMzkNCgkJCWMtNy40LDEuOTc5LTEyLjg1Nyw4LjcxMS0xMi44NTcsMTYuNzM0YzAsOS41NzgsNy43NjQsMTcuMzQxLDE3LjM0MSwxNy4zNDFWMTI1LjFoMTEwLjU0OFY1NS43MzdIMzQuNzM3eiBNMTcuMzk2LDYyLjY3NA0KCQkJYy0zLjgzMiwwLTYuOTM3LTMuMTA2LTYuOTM3LTYuOTM3YzAtMy44MzIsMy4xMDUtNi45MzcsNi45MzctNi45MzdzNi45MzcsMy4xMDUsNi45MzcsNi45MzcNCgkJCUMyNC4zMzMsNTkuNTY4LDIxLjIyOCw2Mi42NzQsMTcuMzk2LDYyLjY3NHogTTI4LjIzNCw3Ny40MTRsMTcuMzE4LTE3LjM0Mkg1Ni4zOUwzOS4wNzMsNzcuNDE0SDI4LjIzNHogTTQ5LjkxMSw3Ny40MTQNCgkJCWwxNy4zMTctMTcuMzQyaDEwLjgzOEw2MC43NDksNzcuNDE0SDQ5LjkxMXogTTcxLjU4Nyw3Ny40MTRsMTcuMzE3LTE3LjM0MmgxMC44MzhMODIuNDI1LDc3LjQxNEg3MS41ODd6IE0xMDQuMTAyLDc3LjQxNEg5My4yNjQNCgkJCWwxNy4zMTYtMTcuMzQyaDEwLjgzOEwxMDQuMTAyLDc3LjQxNHoiLz4NCgk8L2c+DQo8L2c+DQo8L3N2Zz4NCg==',\n            'taxonomies' => ['post_tag'],\n            'show_in_rest' => true,\n            'rest_base' => 'episodes',\n            // 'rest_controller_class' => ...\n        ];\n\n        new \\Podlove\\Podcast_Post_Meta_Box();\n\n        $args = apply_filters('podlove_post_type_args', $args);\n\n        register_post_type('podcast', $args);\n\n        add_action('admin_menu', [$this, 'create_menu']);\n\n        add_action('admin_menu', [$this, 'create_modules_menu_entry'], 100);\n        add_action('admin_menu', [$this, 'create_expert_settings_menu_entry'], 200);\n        add_action('admin_menu', [$this, 'create_tools_menu_entry'], 250);\n        add_action('admin_menu', [$this, 'create_support_menu_entry'], 300);\n\n        add_action('after_delete_post', [$this, 'delete_trashed_episodes']);\n        add_filter('pre_get_posts', [$this, 'enable_tag_and_category_search']);\n        add_filter('post_class', [$this, 'add_post_class']);\n        add_filter('close_comments_for_post_types', [$this, 'compatibility_with_auto_comment_closing']);\n\n        add_filter('get_the_excerpt', [$this, 'default_excerpt_to_episode_summary'], 10, 2);\n\n        add_filter('save_post_podcast', [$this, 'save_post_podcast'], 10, 3);\n    }\n\n    public function save_post_podcast($post_id, $post, $update)\n    {\n        // NOTE: not checking for $update and instead always calling this might\n        // fix an issue where the episode is not writable for some people.\n\n        // if ($update === false) {\n        $this->handle_episode_created($post_id, $post);\n        // }\n    }\n\n    /**\n     * Add .post CSS class to post-classes to work around themes using the\n     * .post class to style articles.\n     *\n     * @param array $classes\n     *\n     * @return array\n     */\n    public function add_post_class($classes)\n    {\n        if (get_post_type() == 'podcast') {\n            $classes[] = 'post';\n        }\n\n        return $classes;\n    }\n\n    /**\n     * Add compatibility for automatic comment closing.\n     *\n     * WordPress has an option to automatically close commenting after some time.\n     * By default, it only works for \"post\" post types. But there is a hook to\n     * add post types.\n     *\n     * @param array $post_types\n     *\n     * @return array\n     */\n    public function compatibility_with_auto_comment_closing($post_types)\n    {\n        $post_types[] = 'podcast';\n\n        return $post_types;\n    }\n\n    /**\n     * Enable tag and category search results for all post types.\n     *\n     * @param mixed $query\n     *\n     * @return mixed\n     */\n    public function enable_tag_and_category_search($query)\n    {\n        // @see https://github.com/podlove/podlove-publisher/issues/1017\n        if (defined('PODLOVE_DISABLE_TAG_AND_CATEGORY_SEARCH') && constant('PODLOVE_DISABLE_TAG_AND_CATEGORY_SEARCH')) {\n            return $query;\n        }\n\n        // Only modify main queries on the frontend\n        if (!$query->is_main_query() || is_admin()) {\n            return $query;\n        }\n\n        if ((is_category() || is_tag()) && empty($query->query_vars['suppress_filters'])) {\n            $post_type = $query->get('post_type');\n\n            // Ensure post_type is an array with 'podcast' included\n            $post_type = empty($post_type) ? ['post'] : (array) $post_type;\n\n            if (!in_array('podcast', $post_type)) {\n                $post_type[] = 'podcast';\n            }\n\n            $query->set('post_type', $post_type);\n        }\n\n        return $query;\n    }\n\n    public function default_excerpt_to_episode_summary($excerpt, $post)\n    {\n        if (get_post_type($post) !== 'podcast') {\n            return $excerpt;\n        }\n\n        $episode = \\Podlove\\Model\\Episode::find_or_create_by_post_id($post->ID);\n        $excerpt = strlen($episode->summary || '') > 0 ? $episode->summary : $excerpt;\n\n        return apply_filters('wp_trim_excerpt', $excerpt);\n    }\n\n    public function create_menu()\n    {\n        // create new top-level menu\n        $hook = add_menu_page(\n            // $page_title\n            'Podlove Plugin Settings',\n            // $menu_title\n            'Podlove',\n            // $capability\n            'podlove_read_dashboard',\n            // $menu_slug\n            self::SETTINGS_PAGE_HANDLE,\n            // $function\n            function () { // see \\Podlove\\Settings\\Dashboard\n            },\n            // $icon_url\n            'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjQsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCIgWw0KCTwhRU5USVRZIG5zX2V4dGVuZCAiaHR0cDovL25zLmFkb2JlLmNvbS9FeHRlbnNpYmlsaXR5LzEuMC8iPg0KCTwhRU5USVRZIG5zX2FpICJodHRwOi8vbnMuYWRvYmUuY29tL0Fkb2JlSWxsdXN0cmF0b3IvMTAuMC8iPg0KCTwhRU5USVRZIG5zX2dyYXBocyAiaHR0cDovL25zLmFkb2JlLmNvbS9HcmFwaHMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfdmFycyAiaHR0cDovL25zLmFkb2JlLmNvbS9WYXJpYWJsZXMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfaW1yZXAgImh0dHA6Ly9ucy5hZG9iZS5jb20vSW1hZ2VSZXBsYWNlbWVudC8xLjAvIj4NCgk8IUVOVElUWSBuc19zZncgImh0dHA6Ly9ucy5hZG9iZS5jb20vU2F2ZUZvcldlYi8xLjAvIj4NCgk8IUVOVElUWSBuc19jdXN0b20gImh0dHA6Ly9ucy5hZG9iZS5jb20vR2VuZXJpY0N1c3RvbU5hbWVzcGFjZS8xLjAvIj4NCgk8IUVOVElUWSBuc19hZG9iZV94cGF0aCAiaHR0cDovL25zLmFkb2JlLmNvbS9YUGF0aC8xLjAvIj4NCl0+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zOng9IiZuc19leHRlbmQ7IiB4bWxuczppPSImbnNfYWk7IiB4bWxuczpncmFwaD0iJm5zX2dyYXBoczsiDQoJIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiDQoJIHZpZXdCb3g9IjAgMCAxMjggMTI4IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxtZXRhZGF0YT4NCgk8c2Z3ICB4bWxucz0iJm5zX3NmdzsiPg0KCQk8c2xpY2VzPjwvc2xpY2VzPg0KCQk8c2xpY2VTb3VyY2VCb3VuZHMgIGhlaWdodD0iMTI3Ljk4MyIgd2lkdGg9IjcyLjQyNCIgYm90dG9tTGVmdE9yaWdpbj0idHJ1ZSIgeD0iMjcuMzk2IiB5PSIwLjUwNSI+PC9zbGljZVNvdXJjZUJvdW5kcz4NCgk8L3Nmdz4NCjwvbWV0YWRhdGE+DQo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNOTIuMjczLDEyNy45OTVIMzUuOTQzYy00LjQ0NCwwLTguMDQ3LTMuNTgxLTguMDQ3LTcuOTk5VjguMDExYzAtNC40MTcsMy42MDMtNy45OTksOC4wNDctNy45OTloNTYuMzMxDQoJYzQuNDQzLDAsOC4wNDcsMy41ODIsOC4wNDcsNy45OTl2MTExLjk4NUMxMDAuMzIsMTI0LjQxNCw5Ni43MTgsMTI3Ljk5NSw5Mi4yNzMsMTI3Ljk5NXogTTYzLjYwNSwxMTEuOTk2DQoJYzEzLjMzMywwLDI0LjE0MS0xMC43NDMsMjQuMTQxLTIzLjk5N2MwLTEzLjI1MS0xMC44MDktMjMuOTk1LTI0LjE0MS0yMy45OTVjLTEzLjMzMywwLTI0LjE0MSwxMC43NDQtMjQuMTQxLDIzLjk5NQ0KCUMzOS40NjQsMTAxLjI1Myw1MC4yNzMsMTExLjk5Niw2My42MDUsMTExLjk5NnogTTkyLjI3Myw4LjAxMUgzNS45NDN2NDcuOTkzaDU2LjMzMVY4LjAxMUw5Mi4yNzMsOC4wMTF6IE02My42MDUsNzkuMjQ2DQoJYzQuODY0LDAsOC44MDYsMy45Miw4LjgwNiw4Ljc1M2MwLDQuODM2LTMuOTQsOC43NTUtOC44MDYsOC43NTVjLTQuODY0LDAtOC44MDctMy45MTktOC44MDctOC43NTUNCglDNTQuNzk5LDgzLjE2Niw1OC43NDIsNzkuMjQ2LDYzLjYwNSw3OS4yNDZ6Ii8+DQo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNNjMuOTkyLDIyLjk3MmM1LjAzMy0xMS4yNSwyMC4yOTktOS4wOTgsMjAuMzk4LDQuNTM0YzAuMDU3LDcuODA5LTIwLjM2OSwyMS44NzEtMjAuMzY5LDIxLjg3MQ0KCXMtMjAuNDctMTMuOTI5LTIwLjQxMy0yMS43ODlDNDMuNzA4LDEzLjk4OCw1OC43MTIsMTEuMjUzLDYzLjk5MiwyMi45NzJ6Ii8+DQo8L3N2Zz4NCg=='\n            // $position\n        );\n\n        new \\Podlove\\Settings\\Dashboard(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\Analytics(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\Podcast(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\EpisodeAsset(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\Feed(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\Templates(self::SETTINGS_PAGE_HANDLE);\n        new \\Podlove\\Settings\\FileType(self::SETTINGS_PAGE_HANDLE);\n\n        do_action('podlove_register_settings_pages', self::SETTINGS_PAGE_HANDLE);\n    }\n\n    public function create_modules_menu_entry()\n    {\n        new \\Podlove\\Settings\\Modules(self::SETTINGS_PAGE_HANDLE);\n    }\n\n    public function create_expert_settings_menu_entry()\n    {\n        new \\Podlove\\Settings\\Settings(self::SETTINGS_PAGE_HANDLE);\n    }\n\n    public function create_tools_menu_entry()\n    {\n        new \\Podlove\\Settings\\Tools(self::SETTINGS_PAGE_HANDLE);\n    }\n\n    public function create_support_menu_entry()\n    {\n        new \\Podlove\\Settings\\Support(self::SETTINGS_PAGE_HANDLE);\n    }\n\n    /**\n     * Hook into post deletion and remove associated episode.\n     *\n     * @param int $post_id\n     */\n    public function delete_trashed_episodes($post_id)\n    {\n        $episode = Model\\Episode::find_one_by_post_id($post_id);\n\n        if (!$episode) {\n            return;\n        }\n\n        if ($media_files = Model\\MediaFile::find_all_by_episode_id($episode->id)) {\n            foreach ($media_files as $media_file) {\n                $media_file->delete();\n            }\n        }\n\n        do_action('podlove_delete_episode', $episode);\n\n        $episode->delete();\n    }\n\n    private function handle_episode_created($post_id, $post)\n    {\n        $episode = Episode::find_one_by_property('post_id', $post_id);\n\n        // bail if episode already exists\n        if ($episode) {\n            return;\n        }\n\n        // otherwise create new episode\n        $episode = new Episode();\n        $episode->post_id = $post_id;\n        $episode->type = 'full';\n        $episode->save();\n\n        do_action('podlove_episode_created', $episode);\n    }\n}\n"
  },
  {
    "path": "lib/repair.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass Repair\n{\n    public const REPAIR_LOG_KEY = 'podlove_repair_log';\n\n    /**\n     * Register hooks.\n     */\n    public static function init()\n    {\n        self::maybe_repair();\n\n        add_action('admin_notices', function () {\n            self::print_and_clear_repair_log();\n        });\n    }\n\n    public static function maybe_repair()\n    {\n        if (isset($_GET['repair']) && $_GET['repair']) {\n            self::do_repair();\n        }\n    }\n\n    public static function do_repair()\n    {\n        if (!current_user_can('administrator')) {\n            exit;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_tools')) {\n            http_response_code(401);\n            exit;\n        }\n\n        self::clear_repair_log();\n\n        self::clear_podlove_cache();\n        self::clear_podlove_image_cache();\n        self::flush_rewrite_rules();\n        self::remove_duplicate_episodes();\n        self::repair_missing_columns();\n\n        // hook for modules to add their repair methods\n        do_action('podlove_repair_do_repair');\n\n        wp_redirect(admin_url('admin.php?page='.htmlspecialchars($_REQUEST['page'] ?? '')));\n        exit;\n    }\n\n    public static function add_to_repair_log($message, $status = 'updated')\n    {\n        $log = get_option(self::REPAIR_LOG_KEY, []);\n\n        if (!isset($log[$status])) {\n            $log[$status] = [];\n        }\n\n        $log[$status][] = $message;\n        update_option(self::REPAIR_LOG_KEY, $log);\n    }\n\n    public static function clear_podlove_cache()\n    {\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        $cache->purge();\n        self::add_to_repair_log(__('Podlove cache cleared', 'podlove-podcasting-plugin-for-wordpress'));\n        self::add_to_repair_log('<strong>'.__('If you are using a caching plugin like \"WP Super Cache\", \"W3 Total Cache\" or \"Comet Cache\", you need to wipe their caches.', 'podlove-podcasting-plugin-for-wordpress').'</strong>', 'notice');\n    }\n\n    public static function clear_podlove_image_cache()\n    {\n        \\Podlove\\Model\\Image::flush_cache();\n        self::add_to_repair_log(__('Podlove image cache cleared', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    // this should create a conflict with user aided resolution\n    public static function remove_duplicate_episodes()\n    {\n        global $wpdb;\n\n        // find duplicate episodes\n        $sql = 'SELECT post_id, COUNT(*) cnt FROM '.Model\\Episode::table_name().' GROUP BY post_id HAVING cnt > 1';\n        $duplicate_post_ids = $wpdb->get_col($sql, 0);\n\n        if ($duplicate_post_ids && count($duplicate_post_ids)) {\n            foreach ($duplicate_post_ids as $post_id) {\n                // only keep first created episode entry\n                $sql = $wpdb->prepare(\n                    'DELETE FROM\n\t\t\t\t\t\t'.Model\\Episode::table_name().'\n\t\t\t\t\tWHERE post_id = %d AND id != (SELECT id FROM (\n\t\t\t\t\t\tSELECT\n\t\t\t\t\t\t\tid\n\t\t\t\t\t\tFROM\n\t\t\t\t\t\t\t'.Model\\Episode::table_name().'\n\t\t\t\t\t\tWHERE\n\t\t\t\t\t\t\tpost_id = %d\n\t\t\t\t\t\tORDER BY\n\t\t\t\t\t\t\tid ASC\n\t\t\t\t\t\tLIMIT 1\n\t\t\t\t\t) x)',\n                    $post_id,\n                    $post_id\n                );\n                $wpdb->query($sql);\n            }\n            self::add_to_repair_log(\n                sprintf(\n                    __('Removed duplicate episode datasets (%s) You should verify that they are correct.', 'podlove-podcasting-plugin-for-wordpress'),\n                    implode(', ', array_map(function ($post_id) {\n                        $link = \\get_edit_post_link($post_id);\n                        $title = \\get_the_title($post_id);\n\n                        return sprintf('<a href=\"%s\" target=\"_blank\">%s</a>', $link, $title);\n                    }, $duplicate_post_ids))\n                )\n            );\n        }\n    }\n\n    public static function page()\n    {\n        ?>\n\t\t<p>\n\t\t\t<a href=\"<?php echo esc_url(admin_url('admin.php?page='.$_REQUEST['page'].'&repair=1&nonce='.wp_create_nonce('podlove_tools'))); ?>\" class=\"button\">\n\t\t\t\t<?php echo __('Attempt Repair', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</a>\n\t\t</p>\n\t\t<p>\n\t\t\t<?php echo __('There are a few occasional issues that are hard to avoid but easy to fix.\n\t\t\tTo make resolving those issues easier, instead of giving you an instruction on what to do,\n\t\t\tpressing this button will attempt to fix it for you.\n\t\t\tThis is what happens:', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t<ul class=\"ul-disc\">\n\t\t\t\t<li>\n\t\t\t\t\t<strong><?php echo __('clears Podlove cache', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t<?php echo __('Sometimes an issue is already fixed but you still see the faulty output. Clearing the cache avoids this. However, if you use a third party caching plugin, you should clear that cache, too.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<strong><?php echo __('clears Podlove image cache', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t<?php echo __('Podlove should notice automatically when an image changes and replace it after a while. If you want to enforce the refresh, this will do it.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<strong><?php echo __('flushes WordPress rewrite rules', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t<?php echo __('If you have strange behaviour in some sites or pages are not found which should exist, this might solve it.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<strong><?php echo __('repairs missing database columns', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t<?php echo __('Adds newer Podlove columns if they are missing. This is safe and only affects Podlove tables.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</li>\n\t\t\t\t<?php // hook for modules to add their repair method descriptions?>\n\t\t\t\t<?php foreach (apply_filters('podlove_repair_descriptions', []) as $entry) { ?>\n\t\t\t\t\t<li><?php echo $entry; ?></li>\n\t\t\t\t<?php } ?>\n\t\t\t</ul>\n\t\t\t<?php echo __('Feel free to press this button as often as you like. Worst case scenario: nothing happens.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n    }\n\n    private static function clear_repair_log()\n    {\n        update_option(self::REPAIR_LOG_KEY, []);\n    }\n\n    private static function flush_rewrite_rules()\n    {\n        flush_rewrite_rules();\n        self::add_to_repair_log(__('Rewrite rules flushed', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    private static function print_and_clear_repair_log()\n    {\n        $log = get_option(self::REPAIR_LOG_KEY, []);\n\n        if (empty($log)) {\n            return;\n        } ?>\n\t\t<?php foreach ($log as $status => $messages) { ?>\n\t\t\t<div class=\"<?php echo $status; ?>\" <?php echo ($status == 'notice') ? 'style=\"border-left: 4px solid #ffba00;\"' : ''; ?>>\n\t\t\t\t<?php if (count($messages) > 1) { ?>\n\t\t\t\t\t<ul class=\"ul-disc\">\n\t\t\t\t\t\t<?php foreach ($messages as $entry) { ?>\n\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t<?php echo $entry; ?>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t</ul>\n\t\t\t\t<?php } else { ?>\n\t\t\t\t\t<?php foreach ($messages as $entry) { ?>\n\t\t\t\t\t\t<p><?php echo $entry; ?></p>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t<?php } ?>\n\t\t\t</div>\n\t\t<?php } ?>\n\n\t\t<?php\n        self::clear_repair_log();\n    }\n\n    private static function repair_missing_columns()\n    {\n        global $wpdb;\n\n        $columns = [\n            Model\\Episode::table_name() => [\n                'title' => 'TEXT',\n                'number' => 'INT UNSIGNED',\n                'type' => 'VARCHAR(10)',\n                'soundbite_start' => 'VARCHAR(255)',\n                'soundbite_duration' => 'VARCHAR(255)',\n                'soundbite_title' => 'VARCHAR(255)',\n                'slug_frozen' => 'TINYINT DEFAULT 0'\n            ],\n            Model\\MediaFile::table_name() => [\n                'active' => 'TINYINT',\n            ],\n        ];\n\n        if (Modules\\Shownotes\\Model\\Entry::table_exists()) {\n            $columns[Modules\\Shownotes\\Model\\Entry::table_name()] = [\n                'affiliate_url' => 'TEXT',\n                'hidden' => 'INT',\n                'image' => 'TEXT',\n            ];\n        }\n\n        $added = [];\n        $errors = [];\n\n        foreach ($columns as $table => $table_columns) {\n            foreach ($table_columns as $column => $definition) {\n                if (self::column_exists($table, $column)) {\n                    continue;\n                }\n\n                $sql = sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `%s` %s',\n                    esc_sql($table),\n                    esc_sql($column),\n                    $definition\n                );\n\n                $result = $wpdb->query($sql);\n                if ($result !== false) {\n                    $added[] = \"{$table}.{$column}\";\n                } else {\n                    $errors[] = [\n                        'table' => $table,\n                        'column' => $column,\n                        'error' => $wpdb->last_error,\n                    ];\n                }\n            }\n        }\n\n        if (!empty($added)) {\n            self::add_to_repair_log(\n                sprintf(\n                    __('Added missing database columns: %s', 'podlove-podcasting-plugin-for-wordpress'),\n                    implode(', ', $added)\n                )\n            );\n        } else {\n            self::add_to_repair_log(\n                __('No missing Podlove columns found.', 'podlove-podcasting-plugin-for-wordpress')\n            );\n        }\n\n        if (!empty($errors)) {\n            $has_permission_error = false;\n            foreach ($errors as $error) {\n                if (stripos($error['error'], 'ALTER command denied') !== false\n                    || stripos($error['error'], 'access denied') !== false\n                    || stripos($error['error'], 'permission') !== false\n                ) {\n                    $has_permission_error = true;\n\n                    break;\n                }\n            }\n\n            $details = implode(', ', array_map(function ($error) {\n                return \"{$error['table']}.{$error['column']}\";\n            }, $errors));\n\n            if ($has_permission_error) {\n                self::add_to_repair_log(\n                    sprintf(\n                        __('Could not add some columns due to database permissions (ALTER denied). Ask your host to grant ALTER privileges or run the ALTER statements manually. Missing: %s', 'podlove-podcasting-plugin-for-wordpress'),\n                        $details\n                    ),\n                    'notice'\n                );\n            } else {\n                self::add_to_repair_log(\n                    sprintf(\n                        __('Could not add some columns due to database errors. Missing: %s', 'podlove-podcasting-plugin-for-wordpress'),\n                        $details\n                    ),\n                    'notice'\n                );\n            }\n        }\n    }\n\n    private static function column_exists($table, $column)\n    {\n        global $wpdb;\n\n        $sql = $wpdb->prepare(\n            'SHOW COLUMNS FROM `'.$table.'` LIKE %s',\n            $column\n        );\n\n        return (bool) $wpdb->get_var($sql);\n    }\n}\n"
  },
  {
    "path": "lib/settings/analytics.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Jobs\\DownloadTimedAggregatorJob;\nuse Podlove\\Model;\n\nclass Analytics\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n    public $table;\n\n    public function __construct($handle)\n    {\n        if (\\Podlove\\get_setting('tracking', 'mode') !== 'ptm_analytics') {\n            return;\n        }\n\n        self::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Analytics', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Analytics', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'podlove_read_analytics',\n            // $menu_slug\n            'podlove_analytics',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        // add_action( 'admin_init', array( $this, 'process_form' ) );\n        add_action('admin_init', [$this, 'scripts_and_styles']);\n\n        add_action('load-'.self::$pagehook, [$this, 'init_list_table']);\n\n        if (isset($_GET['action']) && $_GET['action'] == 'show') {\n            add_action('load-'.self::$pagehook, function () {\n                add_action('add_meta_boxes_'.\\Podlove\\Settings\\Analytics::$pagehook, function () {\n                    add_meta_box(\\Podlove\\Settings\\Analytics::$pagehook.'_release_downloads_chart', __('Downloads over Time', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Analytics::chart', \\Podlove\\Settings\\Analytics::$pagehook, 'normal');\n                    add_meta_box(\\Podlove\\Settings\\Analytics::$pagehook.'_numbers', __('Download Numbers', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Analytics::numbers', \\Podlove\\Settings\\Analytics::$pagehook, 'normal');\n                });\n                do_action('add_meta_boxes_'.\\Podlove\\Settings\\Analytics::$pagehook);\n\n                wp_enqueue_script('postbox');\n            });\n        }\n\n        if (isset($_GET['action']) && in_array($_GET['action'], ['export-csv', 'export-json'])) {\n            $this->handle_export($_GET['action']);\n        }\n\n        add_filter('screen_settings', [$this, 'screen_settings'], 10, 2);\n        add_action('wp_dashboard_setup', [$this, 'dashboard_setup']);\n    }\n\n    public function dashboard_setup()\n    {\n        if (!current_user_can('podlove_read_analytics')) {\n            return;\n        }\n\n        wp_add_dashboard_widget(\n            'podlove_analytics_recent_downloads',\n            __('Podlove - Recent Downloads', 'podlove-podcasting-plugin-for-wordpress'),\n            [$this, 'dashboard_widget_recent_downloads']\n        );\n    }\n\n    public function dashboard_widget_recent_downloads()\n    {\n        ?>\n        <div id=\"total-chart\" style=\"height: 200px; width: 100%;\"></div>\n        <div class=\"clear\"></div>\n        <?php\n    }\n\n    public function handle_export($action)\n    {\n        $posts = isset($_GET['post']) ? $_GET['post'] : [];\n        $ids = count($posts) ? implode(',', $posts) : '';\n        $format = $action == 'export-csv' ? 'csv' : 'json';\n        $route_base = '/podlove/v1/analytics/episodes';\n        $route = $ids ? $route_base.'/'.$ids : $route_base;\n\n        self::rest_api_call($route, ['format' => $format]);\n    }\n\n    /**\n     * API Call against WP REST API.\n     *\n     * @param string $url\n     * @param array  $params\n     */\n    public static function rest_api_call($url, $params)\n    {\n        $request = new \\WP_REST_Request('GET', $url);\n        $request->set_query_params($params);\n        $response = rest_do_request($request);\n\n        $server = rest_get_server();\n        $data = $server->response_to_data($response, false);\n\n        header('Cache-Control: no-cache, must-revalidate');\n        header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');\n        header('Content-type: application/json');\n        echo wp_wp_json_encode($data);\n\n        exit;\n    }\n\n    // needs to be initialized here so columns become configurable\n    public function init_list_table()\n    {\n        $hidden_cols = DownloadTimedAggregatorJob::get_hidden_groups();\n\n        // set default hidden cols\n        if (!is_array($hidden_cols)) {\n            update_user_meta(\n                get_current_user_id(),\n                DownloadTimedAggregatorJob::get_hidden_groups_key(),\n                ['3y', '2y', '3q', '2q', '3w', '2w']\n            );\n        }\n\n        $this->table = new \\Podlove\\Downloads_List_Table();\n    }\n\n    public function screen_settings($status, $args)\n    {\n        if ($args->base !== 'podlove_page_podlove_analytics') {\n            return $status;\n        }\n\n        if (!isset($_GET['action']) || $_GET['action'] !== 'show') {\n            return $status;\n        }\n\n        $tiles = [\n            'download_source' => __('Download Source', 'podlove-podcasting-plugin-for-wordpress'),\n            'download_context' => __('Download Context', 'podlove-podcasting-plugin-for-wordpress'),\n            'asset' => __('Asset', 'podlove-podcasting-plugin-for-wordpress'),\n            'podcast_client' => __('Podcast Client', 'podlove-podcasting-plugin-for-wordpress'),\n            'operating_system' => __('Operating System', 'podlove-podcasting-plugin-for-wordpress'),\n            'geo_location' => __('Client Location', 'podlove-podcasting-plugin-for-wordpress'),\n        ];\n\n        $option = get_option('podlove_analytics_tiles', []);\n        $option_compare_avg = get_option('podlove_analytics_compare_avg', true);\n\n        $status .= '\n\t\t<h5>'.__('Show Analytics Tiles', 'podlove-podcasting-plugin-for-wordpress').\"</h5>\n\t\t<div class='metabox-prefs'>\";\n\n        foreach ($tiles as $id => $title) {\n            $status .= \"<label for='{$id}'>\n\t\t\t\t<input \".checked(!isset($option[$id]) || $option[$id], true, false).\" type='checkbox' value='{$id}' name='podlove_analytics_tiles' id='{$id}' />\n\t\t\t\t{$title}\n\t\t\t</label>\";\n        }\n\n        $status .= '</div>';\n\n        $status .= '\n\t\t<h5>'.__('Compare with Average Episode', 'podlove-podcasting-plugin-for-wordpress').\"</h5>\n\t\t<div class='metabox-prefs'>\";\n\n        $id = 'average-episode';\n        $status .= \"<label for='{$id}'>\n\t\t\t<input \".checked($option_compare_avg, true, false).\" type='checkbox' value='{$id}' name='podlove_analytics_compare_avg' id='{$id}' />\n\t\t\t\".__('Display average episode data', 'podlove-podcasting-plugin-for-wordpress').'\n\t\t</label>';\n\n        $status .= '</div>';\n\n        return $status;\n    }\n\n    public function scripts_and_styles()\n    {\n        if (!isset($_REQUEST['page']) && !isset($GLOBALS['pagenow'])) {\n            return;\n        }\n\n        $is_analytics_page = isset($_REQUEST['page']) && $_REQUEST['page'] == 'podlove_analytics';\n        $is_dashboard = isset($GLOBALS['pagenow']) && $GLOBALS['pagenow'] == 'index.php';\n\n        if (!$is_analytics_page && !$is_dashboard) {\n            return;\n        }\n\n        // libraries\n        wp_register_script('podlove-d3-js', \\Podlove\\PLUGIN_URL.'/js/admin/d3.min.js');\n        wp_register_script('podlove-crossfilter-js', \\Podlove\\PLUGIN_URL.'/js/admin/crossfilter.min.js');\n\n        // application\n\n        wp_register_script('podlove-analytics-js', \\Podlove\\PLUGIN_URL.'/js/dist/podcast-stats.js', ['podlove-d3-js', 'podlove-crossfilter-js', 'underscore']);\n\n        wp_localize_script('podlove-analytics-js', 'podlove_episode_names', self::episode_ids_to_names_map());\n\n        wp_enqueue_script('podlove-analytics-js');\n\n        wp_register_style('podlove-dc-css', \\Podlove\\PLUGIN_URL.'/css/dc.css', [], \\Podlove\\get_plugin_header('Version'));\n        wp_enqueue_style('podlove-dc-css');\n    }\n\n    public static function episode_ids_to_names_map()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\t\tSELECT\n\t\t\t  e.id, p.post_title\n\t\t\tFROM\n\t\t\t  '.$wpdb->posts.' p\n\t\t\t  INNER JOIN `'.Model\\Episode::table_name().'` e ON p.ID = e.`post_id`\n\t\t';\n        $rows = $wpdb->get_results($sql);\n\n        $map = [];\n        foreach ($rows as $row) {\n            $map[$row->id] = $row->post_title;\n        }\n\n        return $map;\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<?php\nif (Model\\DownloadIntentClean::first() === null) {\n    $this->blank_template();\n} else {\n    $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n    switch ($action) {\n        case 'show':\n            $this->show_template();\n\n            break;\n        case 'index':\n        default:\n            $this->view_template();\n\n            break;\n    }\n} ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function blank_template()\n    {\n        ?>\n\n\t\t<h2><?php _e('Podcast Analytics', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t\t<div id=\"welcome-panel\" class=\"welcome-panel\">\n\t\t    <div class=\"welcome-panel-content\">\n\t\t        <h3 style=\"margin-top: 0px\"><?php _e('Welcome to Podlove Publisher Analytics!', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t        <p class=\"about-description\">\n\t\t        \t<?php if (Model\\DownloadIntent::count() < 50) { ?>\n\t\t        \t\t<?php _e('There is not enough tracking data yet. Publish an episode, then come back after a while.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t        \t<?php } else { ?>\n\t\t        \t\t<span class=\"dashicons dashicons-hammer\"></span> <?php _e('Busy crunching numbers. One plus one is&#8230; can you come back in a few minutes?', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t        \t<?php } ?>\n\t\t        </p>\n\t\t        <div class=\"welcome-panel-column-container\">\n\t\t            <div class=\"welcome-panel-column\">\n\t\t                <h4><?php _e('While you wait ...', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t                <ul>\n\t\t                \t<li>\n\t\t                \t\t<a target=\"_blank\" href=\"http://docs.podlove.org/podlove-publisher/guides/download-analytics\" class=\"welcome-icon welcome-learn-more\">\n\t\t                \t\t\t<?php _e('Learn more about how tracking works', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t                \t\t</a>\n\t\t                \t</li>\n\t\t                    <li>\n\t\t                        <a href=\"<?php echo admin_url('post-new.php?post_type=podcast'); ?>\" class=\"welcome-icon welcome-write-blog\">\n\t\t                        \t<?php _e('Add a new episode', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t                        </a>\n\t\t                    </li>\n\t\t                    <li>\n\t\t                        <a href=\"<?php echo home_url(); ?>\" class=\"welcome-icon welcome-view-site\">\n\t\t                        \t<?php _e('View your site', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t                        </a>\n\t\t                    </li>\n\t\t                </ul>\n\t\t            </div>\n\t\t        </div>\n\t\t    </div>\n\t\t</div>\n\n\t\t<?php\n    }\n\n    public function view_template()\n    {\n        $published_episodes = Model\\Episode::count_published(); ?>\n\n\t\t<h2><?php _e('Podcast Analytics', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n        <div style=\"width: 100%\">\n            <?php if ($published_episodes > 0) { ?>\n\n                <div class=\"metabox-holder\">\n                    <div class=\"postbox\">\n                        <h2 class=\"hndle\" style=\"cursor: inherit;\">\n                          <?php _e('Recent Episode Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                        </h2>\n                        <div class=\"inside\">\n\n                          <div id=\"total-chart\" style=\"height: 200px\"></div>\n                          <div class=\"clear\"></div>\n\n                        </div>\n                    </div>\n                </div>\n\n                <?php } ?>\n                <?php if ($published_episodes > 3) { ?>\n\n                        <div class=\"metabox-holder\">\n                            <div class=\"postbox\">\n                                <h2 class=\"hndle\" style=\"cursor: inherit;\">\n                                  <?php _e('Episode Performance / Podcast Growth', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                                </h2>\n                                <div class=\"inside\">\n\n                                  <div id=\"total-abo-chart\" style=\"height: 200px\"></div>\n                                  <div class=\"clear\"></div>\n\n                                </div>\n                            </div>\n                        </div>\n\n            <?php } ?>\n\t\t</div>\n\n\t\t<div class=\"clear\"></div>\n\n\t\t<?php\n$cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n\n        $total = $cache->cache_for('podlove_downloads_total', '\\Podlove\\Model\\DownloadIntentClean::total_downloads', 5 * MINUTE_IN_SECONDS);\n        $last_month = $cache->cache_for('podlove_downloads_last_month', '\\Podlove\\Model\\DownloadIntentClean::prev_month_downloads', DAY_IN_SECONDS);\n        $last_7_days = $cache->cache_for('podlove_downloads_last_7_days', '\\Podlove\\Model\\DownloadIntentClean::last_7days_downloads', HOUR_IN_SECONDS);\n        $last_24_hours = $cache->cache_for('podlove_downloads_last_day', '\\Podlove\\Model\\DownloadIntentClean::last_24hours_downloads', HOUR_IN_SECONDS);\n\n        $crunching_numbers_text = '('.__('crunching numbers&#8230;', 'podlove-podcasting-plugin-for-wordpress').')';\n\n        $status_html = '\n\t\t\t<div class=\"chart-loading\" style=\"display: block;\">\n\t\t\t\t<img src=\"'.admin_url('images/wpspin_light-2x.gif').'\" alt=\"Loading\" width=\"16\" height=\"16\" />\n\t\t\t</div>\n\t\t\t<div class=\"chart-failed\" style=\"display: none;\">Loading Chart failed :(</div>\n\t\t\t<div class=\"chart-nodata\" style=\"display: none;\">No Chart Data</div>\n\t\t'; ?>\n\n\t\t<div class=\"metabox-holder\">\n\t\t\t<div class=\"postbox\">\n\t\t\t\t<h2 class=\"hndle\" style=\"cursor: inherit;\"><?php _e('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\t\t\t\t<div class=\"inside\">\n\n\t\t\t\t\t<div class=\"analytics-metric-container\">\n\t\t\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t\t\t<span class=\"analytics-description\"><?php _e('All Time', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-value\"><?php echo is_numeric($total) ? number_format_i18n($total) : $crunching_numbers_text; ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-subtext\"><?php _e('Downloads of all Episodes', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Last Month', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-value\"><?php echo is_numeric($last_month['downloads']) ? number_format_i18n($last_month['downloads']) : $crunching_numbers_text; ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-subtext\"><?php echo sprintf(__('Downloads in %s', 'podlove-podcasting-plugin-for-wordpress'), $last_month['homan_readable_month']); ?></span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Last 7 Days', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-value\"><?php echo is_numeric($last_7_days) ? number_format_i18n($last_7_days) : $crunching_numbers_text; ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-subtext\"><?php echo __('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Last 24 Hours', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-value\"><?php echo is_numeric($last_24_hours) ? number_format_i18n($last_24_hours) : $crunching_numbers_text; ?></span>\n\t\t\t\t\t\t\t<span class=\"analytics-subtext\"><?php echo __('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div class=\"clear\"></div>\n\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"metabox-holder\">\n\t\t\t<div class=\"postbox\">\n\t\t\t\t<h2 class=\"hndle\" style=\"cursor: inherit;\">\n\t\t\t\t\t<?php _e('Global Analytics', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t  </h2>\n\t\t\t\t<div class=\"inside\" class=\"overflow: hidden\">\n\n\t\t\t\t\t<div id=\"podlove-analytics-app\" style=\"display: flex; align-items: center;\">\n\t\t\t\t\t\t<analytics-date-picker></analytics-date-picker>\n\n\n\t\t\t\t\t</div>\n\n                    <div style=\"margin-top: 12px; display: flex;align-items: center;align-content: center;justify-content: space-around;\">\n                    <section id=\"analytics-global-downloads\" style=\"display: none; display: flex; align-items: center; margin-left: 10px; \">\n                      <h1 class=\"analytics-description\" style=\"padding: 0; order: 2; font-size: 18px;\">\n                        <?php _e('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                      </h1>\n                      <div class=\"chart-loading\" style=\"display: block; margin-right: 10px;\">\n                          <img src=\"<?php echo admin_url('images/wpspin_light-2x.gif'); ?>\" alt=\"Loading\" width=\"16\" height=\"16\" />\n                      </div>\n                      <div id=\"analytics-global-downloads-value\" class=\"analytics-value\" style=\"font-size:32px; line-height: 42px; margin-right: 10px; order: 1\">\n\n                      </div>\n                    </section>\n                    </div>\n\n\t\t\t\t\t\t<div style=\"float: none\"></div>\n\n\t\t\t\t\t\t<!--\n\t\t\t\t\t\t<section id=\"analytics-chart-global-downloads-per-month-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-chart-global-downloads-per-month\" style=\"width: 570px\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Downloads per Month', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\t\t\t\t\t\t-->\n\n\t\t\t\t\t\t<section id=\"analytics-global-top-episodes-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-global-top-episodes\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Top Episodes', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section id=\"analytics-chart-global-assets-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-chart-global-assets\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Episode Asset', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section id=\"analytics-chart-global-clients-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-chart-global-clients\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Podcast Client', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section id=\"analytics-chart-global-systems-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-chart-global-systems\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Operating System', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\t\t\t\t\t\t<section id=\"analytics-chart-global-sources-wrapper\" class=\"chart-wrapper\">\n\t\t\t\t\t\t\t<div id=\"analytics-chart-global-sources\">\n\t\t\t\t\t\t\t\t<h1>\n\t\t\t\t\t\t\t\t\t<?php _e('Download Source', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</h1>\n\n\t\t\t\t\t\t\t\t<?php echo $status_html; ?>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</section>\n\n\n                        <?php if (\\Podlove\\Modules\\Base::is_active('shows')) { ?>\n                            <section id=\"analytics-global-shows-wrapper\" class=\"chart-wrapper\" style=\"height: auto;\">\n                                <div id=\"analytics-global-shows\" style=\"height: auto;\">\n                                    <h1>\n                                        <?php _e('Downloads by Show', 'podlove-podcasting-plugin-for-wordpress'); ?>\n                                    </h1>\n\n                                    <?php echo $status_html; ?>\n                                    <div class=\"chart-content\" style=\"height: auto;\"></div>\n                                </div>\n                            </section>\n                        <?php } ?>\n\n                        <div style=\"clear: both\"></div>\n\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<form id=\"podlove-analytics-export\" method=\"get\">\n\t\t<?php\n$this->table->prepare_items();\n        $this->table->display(); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    public static function numbers()\n    {\n        $episode = Model\\Episode::find_one_by_id((int) $_REQUEST['episode']);\n\n        $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n        echo $cache->cache_for('podlove_analytics_episode'.$episode->id, function () use ($episode) {\n            $post = get_post($episode->post_id);\n\n            $releaseDate = new \\DateTime($post->post_date);\n            $releaseDate->setTime(0, 0, 0);\n\n            $diff = $releaseDate->diff(new \\DateTime());\n            $daysSinceRelease = $diff->days;\n\n            $downloads = [\n                'total' => Model\\DownloadIntentClean::total_by_episode_id($episode->id, '1000 years ago', 'now'),\n                'month' => Model\\DownloadIntentClean::total_by_episode_id($episode->id, '28 days ago', 'yesterday'),\n                'week' => Model\\DownloadIntentClean::total_by_episode_id($episode->id, '7 days ago', 'yesterday'),\n                'yesterday' => Model\\DownloadIntentClean::total_by_episode_id($episode->id, '1 day ago'),\n                'today' => Model\\DownloadIntentClean::total_by_episode_id($episode->id, 'now'),\n            ];\n\n            $peak = Model\\DownloadIntentClean::peak_download_by_episode_id($episode->id);\n\n            ob_start(); ?>\n\n\t\t\t<div class=\"analytics-metric-container\">\n\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Average', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t<span class=\"analytics-value\"><?php echo number_format_i18n($downloads['total'] / ($daysSinceRelease + 1), 1); ?></span>\n\t\t\t\t\t<span class=\"analytics-subtext\"><?php _e('Downloads per Day', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Peak', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t<span class=\"analytics-value\"><?php echo number_format_i18n($peak['downloads']); ?></span>\n\t\t\t\t\t<span class=\"analytics-subtext\"><?php _e('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?><br><?php _e('on', 'podlove-podcasting-plugin-for-wordpress'); ?> <?php echo mysql2date(get_option('date_format'), $peak['theday']); ?></span>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t<span class=\"analytics-description\"><?php _e('Total', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t\t<span class=\"analytics-value\"><?php echo number_format_i18n($downloads['total']); ?></span>\n\t\t\t\t\t<span class=\"analytics-subtext\"><?php _e('Downloads', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"analytics-metric-box\">\n\t\t\t\t\t<table>\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td><?php _e('28 Days', 'podlove-podcasting-plugin-for-wordpress'); ?></td>\n\t\t\t\t\t\t\t\t<td><?php echo number_format_i18n($downloads['month']); ?></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td><?php _e('7 Days', 'podlove-podcasting-plugin-for-wordpress'); ?></td>\n\t\t\t\t\t\t\t\t<td><?php echo number_format_i18n($downloads['week']); ?></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td><?php _e('Yesterday', 'podlove-podcasting-plugin-for-wordpress'); ?></td>\n\t\t\t\t\t\t\t\t<td><?php echo number_format_i18n($downloads['yesterday']); ?></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<td><?php _e('Today', 'podlove-podcasting-plugin-for-wordpress'); ?></td>\n\t\t\t\t\t\t\t\t<td><?php echo number_format_i18n($downloads['today']); ?></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"clear\"></div>\n\t\t\t<?php\n\n            $html = ob_get_contents();\n            ob_end_clean();\n\n            return $html;\n        }, 600); // 10 minutes\n    }\n\n    public function show_template()\n    {\n        $episode = Model\\Episode::find_one_by_id((int) $_REQUEST['episode']);\n        $post = get_post($episode->post_id); ?>\n\n\t\t<h2>\n\t\t\t<?php echo $post->post_title; ?>\n\t\t\t<br><small>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('Released on %s (%d days ago)', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t    mysql2date(get_option('date_format').' '.get_option('time_format'), $post->post_date),\n\t\t\t\t    number_format_i18n($episode->days_since_release())\n\t\t\t\t); ?>\n\t\t\t</small>\n\t\t</h2>\n\n\t\t<style type=\"text/css\">\n\t\th2 small {\n\t\t\tcolor: #666;\n\t\t}\n\t\t</style>\n\n\t\t<div id=\"poststuff\" class=\"metabox-holder\">\n\n\t\t\t<!-- main -->\n\t\t\t<div id=\"post-body\">\n\t\t\t\t<div id=\"post-body-content\">\n\t\t\t\t\t<?php do_meta_boxes(self::$pagehook, 'normal', null); ?>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<br class=\"clear\"/>\n\n\t\t</div>\n\n\t\t<!-- Stuff for opening / closing metaboxes -->\n\t\t<script type=\"text/javascript\">\n\t\tjQuery( document ).ready( function( $ ){\n\t\t\t// close postboxes that should be closed\n\t\t\t$( '.if-js-closed' ).removeClass( 'if-js-closed' ).addClass( 'closed' );\n\t\t\t// postboxes setup\n\t\t\tpostboxes.add_postbox_toggles( '<?php echo self::$pagehook; ?>' );\n\t\t} );\n\t\t</script>\n\n\t\t<form style='display: none' method='get' action=''>\n\t\t\t<?php\nwp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false);\n        wp_nonce_field('meta-box-order', 'meta-box-order-nonce', false); ?>\n\t\t</form>\n\n\t\t<?php\n    }\n\n    public static function chart()\n    {\n        $episode = Model\\Episode::find_one_by_id((int) $_REQUEST['episode']);\n        $post = get_post($episode->post_id); ?>\n\t\t<div id=\"chart-zoom-selection\" class=\"chart-menubar\">\n\t\t\t<span><?php _e('Zoom', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t<a href=\"#\" data-hours=\"24\" class=\"button button-secondary\"><?php _e('1d', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"168\" class=\"button button-secondary\"><?php _e('1w', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"672\" class=\"button button-secondary\"><?php _e('4w', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"0\" class=\"button button-secondary\"><?php _e('all', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t</div>\n\n\t\t<div id=\"chart-grouping-selection\" class=\"chart-menubar\">\n\t\t\t<span><?php _e('Unit', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t<a href=\"#\" data-hours=\"1\" class=\"button button-secondary\"><?php _e('1h', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"2\" class=\"button button-secondary\"><?php _e('2h', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<!-- <a href=\"#\" data-hours=\"3\" class=\"button button-secondary\">3h</a> -->\n\t\t\t<a href=\"#\" data-hours=\"4\" class=\"button button-secondary\"><?php _e('4h', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"6\" class=\"button button-secondary\"><?php _e('6h', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"12\" class=\"button button-secondary\"><?php _e('12h', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"24\" class=\"button button-secondary\"><?php _e('1d', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"168\" class=\"button button-secondary\"><?php _e('1w', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t<a href=\"#\" data-hours=\"672\" class=\"button button-secondary\"><?php _e('4w', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t</div>\n\n\t\t<div id=\"episode-performance-chart\" data-episode=\"<?php echo $episode->id; ?>\">\n\t\t</div>\n\n\t\t<div id=\"episode-range-chart\"></div>\n\n\t\t<section id=\"episode-source-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"download_source\">\n\t\t\t<div id=\"episode-source-chart\">\n\t\t\t\t<h1><?php _e('Download Source', 'podlove-podcasting-plugin-for-wordpress'); ?><a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<section id=\"episode-context-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"download_context\">\n\t\t\t<div id=\"episode-context-chart\">\n\t\t\t\t<h1><?php _e('Download Context', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<section id=\"episode-asset-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"asset\">\n\t\t\t<div id=\"episode-asset-chart\">\n\t\t\t\t<h1><?php _e('Episode Asset', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<section id=\"episode-client-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"podcast_client\">\n\t\t\t<div id=\"episode-client-chart\">\n\t\t\t\t<h1><?php _e('Podcast Client', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<section id=\"episode-system-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"operating_system\">\n\t\t\t<div id=\"episode-system-chart\">\n\t\t\t\t<h1><?php _e('Operating System', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<section id=\"episode-geo-chart-wrapper\" class=\"chart-wrapper\" data-tile-id=\"geo_location\">\n\t\t\t<div id=\"episode-geo-chart\">\n\t\t\t\t<h1><?php _e('Client Location', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"#\" class=\"reset\" style=\"display: none\"><small><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></small></a></h1>\n\t\t\t</div>\n\t\t</section>\n\n\t\t<div style=\"clear: both\"></div>\n\n\t\t<script type=\"text/javascript\">\n\t\tvar assetNames = <?php\n$assets = Model\\EpisodeAsset::all();\n        echo wp_json_encode(\n            array_combine(\n                array_map(function ($a) {\n                    return $a->id;\n                }, $assets),\n                array_map(function ($a) {\n                    return $a->title;\n                }, $assets)\n            )\n        ); ?>;\n\t\t</script>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/dashboard/about.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Dashboard;\n\nclass About\n{\n    public static function content()\n    {\n        \\Podlove\\load_template('settings/dashboard/about');\n    }\n}\n"
  },
  {
    "path": "lib/settings/dashboard/file_validation.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Dashboard;\n\nuse Podlove\\Model;\n\nclass FileValidation\n{\n    public static function content()\n    {\n        global $wpdb;\n\n        $sql = '\n\t\tSELECT\n\t\t\tp.post_status,\n\t\t\tmf.episode_id,\n\t\t\tmf.episode_asset_id,\n\t\t\tmf.size,\n\t\t\tmf.id media_file_id,\n      mf.active\n\t\tFROM\n\t\t\t`'.Model\\MediaFile::table_name().'` mf\n\t\t\tJOIN `'.Model\\Episode::table_name().'` e ON e.id = mf.`episode_id`\n\t\t\tJOIN `'.$wpdb->posts.\"` p ON e.`post_id` = p.`ID`\n\t\tWHERE\n\t\t\tp.`post_type` = 'podcast'\n\t\t\tAND p.post_status in ('private', 'draft', 'publish', 'pending', 'future')\n\t\t\";\n\n        $rows = $wpdb->get_results($sql, ARRAY_A);\n\n        $media_files = [];\n        foreach ($rows as $row) {\n            if (!isset($media_files[$row['episode_id']])) {\n                $media_files[$row['episode_id']] = ['post_status' => $row['post_status']];\n            }\n\n            $media_files[$row['episode_id']][$row['episode_asset_id']] = [\n                'size' => $row['size'],\n                'media_file_id' => $row['media_file_id'],\n                'active' => $row['active']\n            ];\n        }\n\n        $podcast = Model\\Podcast::get();\n        $episodes = $podcast->episodes(['post_status' => ['private', 'draft', 'publish', 'pending', 'future']]);\n        $assets = Model\\EpisodeAsset::all();\n\n        $header = [__('Episode', 'podlove-podcasting-plugin-for-wordpress')];\n        foreach ($assets as $asset) {\n            $header[] = esc_html($asset->title);\n        }\n        $header[] = __('Status', 'podlove-podcasting-plugin-for-wordpress');\n\n        \\Podlove\\load_template('settings/dashboard/file_validation', [\n            'episodes' => $episodes,\n            'assets' => $assets,\n            'media_files' => $media_files,\n            'header' => $header,\n        ]);\n    }\n}\n"
  },
  {
    "path": "lib/settings/dashboard/news.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Dashboard;\n\nclass News\n{\n    // FIXME: https://podlove.org/feed/ is currently broken\n    public static function content()\n    {\n        $feeds = [\n            'podlove' => [\n                'link' => 'https://podlove.org/',\n                'url' => 'https://podlove.org/feed/',\n                'title' => 'Podlove News',\n                'items' => 5,\n                'show_summary' => 1,\n                'show_author' => 0,\n                'show_date' => 1,\n            ],\n        ];\n\n        \\Podlove\\load_template('settings/dashboard/news', ['feeds' => $feeds]);\n    }\n}\n"
  },
  {
    "path": "lib/settings/dashboard/statistics.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Dashboard;\n\nuse Podlove\\Model;\n\nclass Statistics\n{\n    public static function content()\n    {\n        $episode_edit_url = site_url('/wp-admin/edit.php?post_type=podcast');\n        $statistics = self::prepare_statistics();\n\n        \\Podlove\\load_template('settings/dashboard/statistics', [\n            'episode_edit_url' => $episode_edit_url,\n            'statistics' => $statistics,\n        ]);\n    }\n\n    public static function prepare_statistics()\n    {\n        if (($statistics = get_transient('podlove_dashboard_stats')) !== false) {\n            return $statistics;\n        }\n        $episodes = Model\\Episode::find_all_by_time();\n\n        $prev_post = 0;\n        $counted_episodes = 0;\n        $time_stamp_differences = [];\n        $episode_durations = [];\n        $episode_status_count = [\n            'publish' => 0,\n            'private' => 0,\n            'future' => 0,\n            'draft' => 0,\n        ];\n\n        $statistics = [\n            'episodes' => [],\n            'total_episode_length' => 0,\n            'average_episode_length' => 0,\n            'days_between_releases' => 0,\n            'average_media_file_size' => 0,\n            'total_media_file_size' => 0,\n        ];\n\n        foreach ($episodes as $episode_key => $episode) {\n            $post = get_post($episode->post_id);\n            ++$counted_episodes;\n\n            // duration in seconds\n            if (self::duration_to_seconds($episode->duration) > 0) {\n                $episode_durations[$post->ID] = self::duration_to_seconds($episode->duration);\n            }\n\n            // count by post status\n            if (!isset($episode_status_count[$post->post_status])) {\n                $episode_status_count[$post->post_status] = 1;\n            } else {\n                ++$episode_status_count[$post->post_status];\n            }\n\n            // determine time in days since last publication\n            if ($prev_post) {\n                $timestamp_current_episode = new \\DateTime($post->post_date);\n                $timestamp_next_episode = new \\DateTime($prev_post->post_date);\n                $time_stamp_differences[$post->ID] = $timestamp_current_episode->diff($timestamp_next_episode)->days;\n            }\n\n            $prev_post = $post;\n        }\n\n        // Episode Stati\n        $statistics['episodes'] = $episode_status_count;\n        // Number of Episodes\n        $statistics['total_number_of_episodes'] = count($episodes);\n        // Total Episode length\n        $statistics['total_episode_length'] = array_sum($episode_durations);\n        // Calculating average episode in seconds\n        $statistics['average_episode_length'] = count($episode_durations) > 0 ? round(array_sum($episode_durations) / count($episode_durations)) : 0;\n        // Calculate average time until next release in days\n        $statistics['days_between_releases'] = count($time_stamp_differences) > 0 ? round(array_sum($time_stamp_differences) / count($time_stamp_differences)) : 0;\n\n        // Media Files\n        $episodes_to_media_files = function ($media_files, $episode) {\n            return array_merge($media_files, $episode->media_files());\n        };\n        $media_files = array_reduce($episodes, $episodes_to_media_files, []);\n        $valid_media_files = array_filter($media_files, function ($m) {\n            return $m->size > 0;\n        });\n\n        $sum_mediafile_sizes = function ($result, $media_file) {\n            return $result + $media_file->size;\n        };\n        $statistics['total_media_file_size'] = array_reduce($valid_media_files, $sum_mediafile_sizes, 0);\n        $mediafile_count = count($valid_media_files);\n\n        $statistics['average_media_file_size'] = $mediafile_count > 0 ? $statistics['total_media_file_size'] / $mediafile_count : 0;\n\n        set_transient('podlove_dashboard_stats', $statistics, 3600);\n\n        return $statistics;\n    }\n\n    public static function duration_to_seconds($timestring)\n    {\n        return \\Podlove\\NormalPlayTime\\Parser::parse($timestring, 's');\n    }\n}\n"
  },
  {
    "path": "lib/settings/dashboard.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nclass Dashboard\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n\n    public function __construct()\n    {\n        // use \\Podlove\\Podcast_Post_Type::SETTINGS_PAGE_HANDLE to replace\n        // default first item name\n        Dashboard::$pagehook = add_submenu_page(\n            // $parent_slug\n            \\Podlove\\Podcast_Post_Type::SETTINGS_PAGE_HANDLE,\n            // $page_title\n            __('Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Dashboard', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'podlove_read_dashboard',\n            // $menu_slug\n            \\Podlove\\Podcast_Post_Type::SETTINGS_PAGE_HANDLE,\n            // $function\n            [__CLASS__, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_action('load-'.Dashboard::$pagehook, function () {\n            // Adding the meta boxes here, so they can be filtered by the user settings.\n            add_action('add_meta_boxes_'.Dashboard::$pagehook, function () {\n                add_meta_box(Dashboard::$pagehook.'_about', __('About', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Dashboard\\About::content', Dashboard::$pagehook, 'side');\n                add_meta_box(Dashboard::$pagehook.'_statistics', __('At a glance', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Dashboard\\Statistics::content', Dashboard::$pagehook, 'normal');\n                // add_meta_box(Dashboard::$pagehook.'_news', __('Podlove News', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Dashboard\\News::content', Dashboard::$pagehook, 'normal');\n\n                do_action('podlove_dashboard_meta_boxes');\n\n                if (current_user_can('administrator')) {\n                    add_meta_box(Dashboard::$pagehook.'_validation', __('Validate Podcast Files', 'podlove-podcasting-plugin-for-wordpress'), '\\Podlove\\Settings\\Dashboard\\FileValidation::content', Dashboard::$pagehook, 'normal');\n                }\n            });\n            do_action('add_meta_boxes_'.Dashboard::$pagehook);\n\n            wp_enqueue_script('postbox');\n            wp_register_script('cornify-js', \\Podlove\\PLUGIN_URL.'/js/admin/cornify.js');\n            wp_enqueue_script('cornify-js');\n        });\n\n        add_action('publish_podcast', function () {\n            delete_transient('podlove_dashboard_stats');\n        });\n    }\n\n    public static function page()\n    {\n        if (apply_filters('podlove_dashboard_page', false) !== false) {\n            return;\n        }\n\n        \\Podlove\\load_template('settings/dashboard/dashboard');\n    }\n}\n"
  },
  {
    "path": "lib/settings/episode_asset.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Model;\n\nclass EpisodeAsset\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n    public const MENU_SLUG = 'podlove_episode_assets_settings_handle';\n\n    public static $pagehook;\n    private static $nonce = 'update_assets';\n\n    public function __construct($handle)\n    {\n        self::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Episode Assets', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Episode Assets', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_action('admin_init', [$this, 'process_form']);\n\n        register_setting(EpisodeAsset::$pagehook, 'podlove_asset_assignment');\n    }\n\n    public function batch_enable()\n    {\n        if (!isset($_REQUEST['episode_asset'])) {\n            return;\n        }\n\n        $asset = Model\\EpisodeAsset::find_by_id($_REQUEST['episode_asset']);\n\n        $episodes = Model\\Episode::all();\n        foreach ($episodes as $episode) {\n            $post_id = $episode->post_id;\n            $post = get_post($post_id);\n\n            // skip deleted podcasts\n            if (!in_array(get_post_status($post), ['pending', 'draft', 'publish', 'future'])) {\n                continue;\n            }\n\n            // skip versions\n            if (get_post_type($post) != 'podcast') {\n                continue;\n            }\n\n            $file = Model\\MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset->id);\n\n            if ($file === null) {\n                $file = new Model\\MediaFile();\n                $file->episode_id = $episode->id;\n                $file->episode_asset_id = $asset->id;\n                $file->active = true;\n                $file->save();\n            } else {\n                if (!$file->active) {\n                    $file->active = true;\n                    $file->save();\n                }\n            }\n\n            do_action('podlove_media_file_content_has_changed', $file->id);\n        }\n\n        $this->redirect('index', null, ['message' => 'media_file_batch_enabled_notice']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['episode_asset'])) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        if (!in_array($action, ['save', 'create', 'delete', 'batch_enable'])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        } elseif ($action === 'batch_enable') {\n            $this->batch_enable();\n        }\n    }\n\n    public function page()\n    {\n        if (isset($_REQUEST['message'])) {\n            if ($_REQUEST['message'] == 'media_file_batch_enabled_notice') {\n                ?>\n\t\t\t\t<div class=\"updated\">\n\t\t\t\t\t<p><?php _e('<strong>Media Files enabled.</strong> These Media Files have been enabled for all existing episodes.', 'podlove-podcasting-plugin-for-wordpress'); ?></p>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            }\n            if ($_REQUEST['message'] == 'media_file_relation_warning') {\n                $asset = Model\\EpisodeAsset::find_one_by_id((int) $_REQUEST['deleted_id']); ?>\n\t\t\t\t<div class=\"error\">\n\t\t\t\t\t<p>\n\t\t\t\t\t\t<strong><?php _e('The asset has not been deleted. Are you aware that the asset is still in use?', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t\t<ul class=\"ul-disc\">\n\t\t\t\t\t\t\t<?php if ($asset->has_active_media_files()) { ?>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<?php echo sprintf(__('There are %s connected media files.', 'podlove-podcasting-plugin-for-wordpress'), count($asset->active_media_files())); ?>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t\t<?php if ($asset->has_asset_assignments()) { ?>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<?php _e('This asset is assigned to episode images or episode chapters.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t\t<?php if ($asset->is_connected_to_feed()) { ?>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<?php _e('A feed uses this asset.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t\t<?php if ($asset->is_connected_to_web_player()) { ?>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<?php _e('The web player uses this asset.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t<a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=delete&amp;episode_asset=<?php echo $asset->id; ?>&amp;force=1&amp;_podlove_nonce=<?php echo wp_create_nonce('update_assets'); ?>\">\n\t\t\t\t\t\t\t<?php _e('delete anyway', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            }\n        } ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php _e('Episode Assets', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=new\" class=\"add-new-h2\"><?php _e('Add New', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\t\t\t<?php\n        $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n        switch ($action) {\n            case 'new':   $this->new_template();\n\n                break;\n            case 'edit':  $this->edit_template();\n\n                break;\n            case 'index': $this->view_template();\n\n                break;\n\n            default:      $this->view_template();\n\n                break;\n        } ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    /**\n     * Process form: save/update a format.\n     */\n    private function save()\n    {\n        if (!isset($_REQUEST['episode_asset'])) {\n            return;\n        }\n\n        $episode_asset = \\Podlove\\Model\\EpisodeAsset::find_by_id($_REQUEST['episode_asset']);\n        $episode_asset->update_attributes($_POST['podlove_episode_asset']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $episode_asset->id);\n        } else {\n            $this->redirect('index', $episode_asset->id);\n        }\n    }\n\n    /**\n     * Process form: create a format.\n     */\n    private function create()\n    {\n        $episode_asset = new \\Podlove\\Model\\EpisodeAsset();\n        $episode_asset->update_attributes($_POST['podlove_episode_asset']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $episode_asset->id);\n        } else {\n            $this->redirect('index');\n        }\n    }\n\n    /**\n     * Process form: delete a format.\n     */\n    private function delete()\n    {\n        if (!isset($_REQUEST['episode_asset'])) {\n            return;\n        }\n\n        $asset = Model\\EpisodeAsset::find_by_id($_REQUEST['episode_asset']);\n\n        if (isset($_REQUEST['force']) && $_REQUEST['force'] || $asset->is_deletable()) {\n            $asset->delete();\n            $this->redirect('index');\n        } else {\n            $this->redirect('index', null, ['message' => 'media_file_relation_warning', 'deleted_id' => $asset->id]);\n        }\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $episode_asset_id\n     * @param mixed      $params\n     */\n    private function redirect($action, $episode_asset_id = null, $params = [])\n    {\n        $page = 'admin.php?page='.self::MENU_SLUG;\n        $show = ($episode_asset_id) ? '&episode_asset='.$episode_asset_id : '';\n        $action = '&action='.$action;\n\n        array_walk($params, function (&$value, $key) {\n            $value = \"&{$key}={$value}\";\n        });\n\n        wp_redirect(admin_url($page.$show.$action.implode('', $params)));\n        exit;\n    }\n\n    private function new_template()\n    {\n        $episode_asset = new \\Podlove\\Model\\EpisodeAsset(); ?>\n\t\t<h3><?php _e('Add New Episode Asset', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<?php\n        $this->form_template($episode_asset, 'create', __('Add New Episode Asset', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    private function view_template()\n    {\n        $table = new \\Podlove\\Episode_Asset_List_Table();\n        $table->prepare_items();\n        $table->display();\n\n        do_action('podlove_before_assign_assets_settings');\n        ?>\n\n\t\t<h3><?php _e('Assign Assets', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t<?php settings_fields(EpisodeAsset::$pagehook);\n        $asset_assignment = Model\\AssetAssignment::get_instance();\n\n        $form_attributes = [\n            'context' => 'podlove_asset_assignment',\n            'form' => false,\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($asset_assignment, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n            $asset_assignment = $form->object;\n            $artwork_options = [\n                '0' => __('Use Podcast Cover', 'podlove-podcasting-plugin-for-wordpress'),\n                'post-thumbnail' => __('Post Thumbnail', 'podlove-podcasting-plugin-for-wordpress'),\n                'manual' => __('Manual URL Entry per Episode', 'podlove-podcasting-plugin-for-wordpress'),\n            ];\n            $episode_assets = Model\\EpisodeAsset::all();\n            foreach ($episode_assets as $episode_asset) {\n                $file_type = $episode_asset->file_type();\n                if ($file_type && $file_type->type === 'image') {\n                    $artwork_options[$episode_asset->id] = sprintf(__('Asset: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($episode_asset->title));\n                }\n            }\n\n            $wrapper->select('image', [\n                'label' => __('Episode Image', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $artwork_options,\n            ]);\n\n            $chapter_file_options = [\n                '0' => __('None', 'podlove-podcasting-plugin-for-wordpress'),\n                'manual' => __('Manual Entry', 'podlove-podcasting-plugin-for-wordpress'),\n            ];\n            $episode_assets = Model\\EpisodeAsset::all();\n            foreach ($episode_assets as $episode_asset) {\n                $file_type = $episode_asset->file_type();\n                if ($file_type && $file_type->type === 'chapters') {\n                    $chapter_file_options[$episode_asset->id] = sprintf(__('Asset: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($episode_asset->title));\n                }\n            }\n            $wrapper->select('chapters', [\n                'label' => __('Episode Chapters', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $chapter_file_options,\n            ]);\n\n            do_action('podlove_asset_assignment_form', $wrapper, $asset_assignment);\n        }); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    private function form_template($episode_asset, $action, $button_text = null)\n    {\n        $raw_formats = \\Podlove\\Model\\FileType::all();\n        $formats = [];\n        foreach ($raw_formats as $format) {\n            $formats[$format->id] = [\n                'title' => $format->title(),\n                'name' => $format->name,\n                'extension' => $format->extension,\n                'type' => $format->type,\n            ];\n        }\n\n        $format_optionlist = array_map(function ($f) {\n            $is_default_transcript_format = $f['type'] === 'transcript' && $f['extension'] === 'vtt';\n\n            return [\n                'value' => $f['title'],\n                'attributes' => sprintf(\n                    'data-type=\"%s\" data-extension=\"%s\" data-name=\"%s\" data-default-for-type=\"%s\"',\n                    esc_attr($f['type']),\n                    esc_attr($f['extension']),\n                    esc_attr($f['name']),\n                    $is_default_transcript_format ? 'transcript' : ''\n                ),\n            ];\n        }, $formats);\n\n        $form_args = [\n            'context' => 'podlove_episode_asset',\n            'hidden' => [\n                'episode_asset' => $episode_asset->id,\n                'action' => $action,\n            ],\n            'attributes' => [\n                'id' => 'podlove_episode_assets',\n            ],\n            'submit_button' => false, // for custom control in form_end\n            'form_end' => function () {\n                echo '<p>';\n                submit_button(__('Save Changes'), 'primary', 'submit', false);\n                echo ' ';\n                submit_button(__('Save Changes and Continue Editing', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit_and_stay', false);\n                echo '</p>';\n            },\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($episode_asset, $form_args, function ($form) use ($format_optionlist) {\n            $f = new \\Podlove\\Form\\Input\\TableWrapper($form);\n            if ($form->object->file_type_id) {\n                $current_file_type = Model\\FileType::find_by_id($form->object->file_type_id)->type;\n            } else {\n                $current_file_type = '';\n            } ?>\n\t\t\t<tr class=\"row_podlove_episode_asset_type\">\n\t\t\t\t<th scope=\"row\" valign=\"top\">\n\t\t\t\t\t<label for=\"podlove_episode_asset_type\"><?php _e('Asset Type', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t</th>\n\t\t\t\t<td>\n\t\t\t\t\t<select name=\"podlove_episode_asset_type\" id=\"podlove_episode_asset_type\">\n\t\t\t\t\t\t<option><?php _e('Please choose ...', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t<?php foreach (Model\\FileType::get_types() as $type) { ?>\n\t\t\t\t\t\t\t<option value=\"<?php echo $type; ?>\" <?php selected($type, $current_file_type); ?>><?php echo $type; ?></option>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t</select>\n\t\t\t\t\t<div id=\"option_storage\" style=\"display:none\"></div>\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t\t<?php\n\n            $f->select('file_type_id', [\n                'label' => __('File Format', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => '',\n                'options' => $format_optionlist,\n            ]);\n\n            $f->string('title', [\n                'label' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Description to identify the media file type to the user in download buttons.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]);\n\n            $f->string('identifier', [\n                'label' => __('Template Identifier', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => sprintf(\n                    __('Used in templates to access the file for this asset from an episode: %s', 'podlove-podcasting-plugin-for-wordpress'),\n                    '<code>episode.file(\"template-identifier\")</code>'\n                ),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $f->checkbox('downloadable', [\n                'label' => __('Downloadable', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Include in download interfaces.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => true,\n            ]); ?>\n\t\t\t<tr>\n\t\t\t\t<th colspan=\"2\">\n\t\t\t\t\t<h3><?php _e('Asset File Name', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t\t\t</th>\n\t\t\t</tr>\n\t\t\t<?php\n            $f->string('suffix', [\n                'label' => __('File Name Suffix', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Optional. Is appended to file name after episode slug.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]); ?>\n\t\t\t<tr class=\"row_podlove_asset_url_preview\">\n\t\t\t\t<th>\n\t\t\t\t\t<?php _e('URL Preview', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</th>\n\t\t\t\t<td>\n\t\t\t\t\t<div id=\"url_preview\" style=\"font-size: 1.5em\"></div>\n\t\t\t\t\t<div id=\"url_template\" style=\"display: none;\"><?php echo Model\\Podcast::get()->get_url_template(); ?></div>\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t\t<?php\n        });\n\n        // hidden fields for JavaScript?>\n\t\t<input type=\"hidden\" id=\"podlove_show_media_file_base_uri\" value=\"<?php echo Model\\Podcast::get()->get_media_file_base_uri(); ?>\">\n\t\t<?php\n    }\n\n    private function edit_template()\n    {\n        $episode_asset = \\Podlove\\Model\\EpisodeAsset::find_by_id($_REQUEST['episode_asset']);\n        echo '<h3>'.sprintf(__('Edit Episode Asset: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($episode_asset->title)).'</h3>';\n        $this->form_template($episode_asset, 'save');\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/file_types.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\n\nclass FileTypes extends Tab\n{\n    public function get_slug()\n    {\n        return 'file-types';\n    }\n\n    public function init()\n    {\n        $this->page_type = 'custom';\n        add_action('podlove_expert_settings_page', [$this, 'register_page']);\n    }\n\n    public function register_page()\n    {\n        $file_type = new \\Podlove\\Settings\\FileType('podlove_settings_settings_handle');\n        $file_type->page();\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/metadata.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Settings;\n\nclass Metadata extends Tab\n{\n    public function get_slug()\n    {\n        return 'metadata';\n    }\n\n    public function init()\n    {\n        add_settings_section(\n            // $id\n            'podlove_settings_episode',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Episode Metadata Settings', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_episode_recording_date',\n            // $title\n            sprintf(\n                '<label for=\"enable_episode_recording_date\">%s</label>',\n                __('Enable recording date field.', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_recording_date]\" id=\"enable_episode_recording_date\" type=\"radio\" value=\"1\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_recording_date'), 1); ?> /> <?php echo __('enable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_recording_date]\" id=\"enable_episode_recording_date\" type=\"radio\" value=\"0\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_recording_date'), 0); ?> /> <?php echo __('disable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_episode_explicit',\n            // $title\n            sprintf(\n                '<label for=\"enable_episode_explicit\">%s</label>',\n                __('Enable explicit content field.', 'podlove-podcasting-plugin-for-wordpress')\n            ), // $callback\n            function () {\n                ?>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_explicit]\" id=\"enable_episode_explicit\" type=\"radio\" value=\"1\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_explicit'), 1); ?> /> <?php echo __('enable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_explicit]\" id=\"enable_episode_explicit\" type=\"radio\" value=\"0\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_explicit'), 0); ?> /> <?php echo __('disable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_episode_license',\n            // $title\n            sprintf(\n                '<label for=\"enable_episode_license\">%s</label>',\n                __('Enable license field.', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_license]\" id=\"enable_episode_license\" type=\"radio\" value=\"1\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_license'), 1); ?> /> <?php echo __('enable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<label>\n\t\t\t\t\t<input name=\"podlove_metadata[enable_episode_license]\" id=\"enable_episode_license\" type=\"radio\" value=\"0\" <?php checked(\\Podlove\\get_setting('metadata', 'enable_episode_license'), 0); ?> /> <?php echo __('disable', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</label>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        register_setting(Settings::$pagehook, 'podlove_metadata');\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/redirects.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Settings;\n\nclass Redirects extends Tab\n{\n    public function get_slug()\n    {\n        return 'redirects';\n    }\n\n    public function init()\n    {\n        add_settings_section(\n            // $id\n            'podlove_settings_redirects',\n            // $title\n            '',\n            // $callback\n            function () {\n                echo '<h3>'.__('Redirects', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_redirect',\n            // $title\n            '',\n            // $callback\n            function () {\n                $redirect_settings = \\Podlove\\get_setting('redirects', 'podlove_setting_redirect');\n\n                if (!is_array($redirect_settings)) {\n                    $redirect_settings = [];\n                } else {\n                    // avoids array-index-based glitches\n                    $redirect_settings = array_values($redirect_settings);\n                } ?>\n\n\t\t\t\t<table id=\"podlove-redirects\" class=\"podlove_alternating\" border=\"0\" cellspacing=\"0\">\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th style=\"width: 55px\"><?php _e('Active', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t<th><?php _e('From URL', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t<th><?php _e('To URL', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t<th><?php _e('Redirect Method', 'podlove-podcasting-plugin-for-wordpress'); ?></th>\n\t\t\t\t\t\t\t<th class=\"count\">\n\t\t\t\t\t\t\t\t<?php _e('Redirects', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t<th class=\"delete\"></th>\n\t\t\t\t\t\t\t<th class=\"move\"></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody id=\"podlove-redirects-table-body\" style=\"min-height: 50px;\">\n\t\t\t\t\t\t<tr style=\"display: none;\">\n\t\t\t\t\t\t\t<td><em><?php _e('No redirects were added yet.', 'podlove-podcasting-plugin-for-wordpress'); ?></em></td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\n\t\t\t\t<script type=\"text/template\" id=\"redirect-row-template\">\n\t\t\t\t<tr data-index=\"{{index}}\">\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<input type=\"checkbox\" name=\"podlove_redirects[podlove_setting_redirect][{{index}}][active]\" value=\"active\">\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<input type=\"text\" class=\"podlove-check-input\" id=\"podlove_redirects_podlove_setting_redirect_{{index}}_from\" name=\"podlove_redirects[podlove_setting_redirect][{{index}}][from]\" value=\"{{redirect-from}}\"><span class=\"podlove-input-status\" data-podlove-input-status-for=\"podlove_redirects_podlove_setting_redirect_{{index}}_from\"></span>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<input type=\"text\" class=\"podlove-check-input\" id=\"podlove_redirects_podlove_setting_redirect_{{index}}_to\" name=\"podlove_redirects[podlove_setting_redirect][{{index}}][to]\" value=\"{{redirect-to}}\"><span class=\"podlove-input-status\" data-podlove-input-status-for=\"podlove_redirects_podlove_setting_redirect_{{index}}_to\"></span>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<select name=\"podlove_redirects[podlove_setting_redirect][{{index}}][code]\">\n\t\t\t\t\t\t\t<option value=\"307\"><?php _e('Temporary Redirect (HTTP Status 307)', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t<option value=\"301\"><?php _e('Permanent Redirect (HTTP Status 301)', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"count\">\n\t\t\t\t\t\t<span data-podlove-input-status-for=\"podlove_redirects_podlove_setting_redirect_{{index}}_count\">{{count}}</span>\n\t\t\t\t\t\t<button class=\"button reset\"><?php _e('reset', 'podlove-podcasting-plugin-for-wordpress'); ?></button>\n\t\t\t\t\t\t<input type=\"hidden\" name=\"podlove_redirects[podlove_setting_redirect][{{index}}][count]\" value=\"{{count}}\">\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"delete\">\n\t\t\t\t\t\t<button class=\"button delete\"><?php _e('delete', 'podlove-podcasting-plugin-for-wordpress'); ?></button>\n\t\t\t\t\t</td>\n\t\t\t\t\t<td class=\"move column-move\"><i class=\"reorder-handle podlove-icon-reorder\"></i></td>\n\t\t\t\t</tr>\n\t\t\t\t</script>\n\n\t\t\t\t<script type=\"text/javascript\">\n\t\t\t\t(function($) {\n\n\t\t\t\t\tvar existing_redirects = <?php echo wp_json_encode(array_values($redirect_settings)); ?>;\n\t\t\t\t\tvar template_id = \"#redirect-row-template\";\n\t\t\t\t\tvar container_id = \"#podlove-redirects\";\n\n\t\t\t\t\tfunction add_row(index, data) {\n\t\t\t\t\t\tvar row = $(template_id).html();\n\n\t\t\t\t\t\trow = row.replace(/\\{\\{index\\}\\}/g, index);\n\t\t\t\t\t\trow = row.replace(/\\{\\{redirect-from\\}\\}/g, data.from ? data.from : \"\");\n\t\t\t\t\t\trow = row.replace(/\\{\\{redirect-to\\}\\}/g, data.to ? data.to : \"\");\n\t\t\t\t\t\trow = row.replace(/\\{\\{count\\}\\}/g, data.count ? data.count : \"0\");\n\n\t\t\t\t\t\t$row = $(row);\n\t\t\t\t\t\t$row.find(\"select option[value=\\\"\" + data.code + \"\\\"]\").prop(\"selected\", true);\n\n\t\t\t\t\t\tif (data.active) {\n\t\t\t\t\t\t\t$row.find(\"input[type=\\\"checkbox\\\"]\").prop(\"checked\", true);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t$(\"tbody\", container_id).append($row);\n\n\t\t\t\t\t\t$row.find(\"input[type=text]:first\").focus();\n\t\t\t\t\t\tclean_up_input();\n\t\t\t\t\t}\n\n\t\t\t\t\t$(document).ready(function() {\n\n\t\t\t\t\t\t$.each(existing_redirects, function(index, entry) {\n\t\t\t\t\t\t\tadd_row(index, entry);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t$(\"#podlove_add_new_rule\").on(\"click\", function () {\n\t\t\t\t\t\t\tadd_row($(\"tbody tr\", container_id).length, {active: \"active\"});\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t$(container_id).on(\"click\", \"td.delete button.delete\", function(e) {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t$(this).closest(\"tr\").remove();\n\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t$(container_id).on(\"click\", \"td.count button.reset\", function(e) {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tvar tr = $(this).closest(\"tr\");\n\t\t\t\t\t\t\ttr.find('.count input[type=hidden]:first').val(0);\n\t\t\t\t\t\t\ttr.find('.count span:first').html(\"0\");\n\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t$(\"tbody\", container_id).sortable({\n\t\t\t\t\t\t\thandle: \".reorder-handle\",\n\t\t\t\t\t\t\thelper: function(e, tr) {\n\t\t\t\t\t\t\t    var $originals = tr.children();\n\t\t\t\t\t\t\t    var $helper = tr.clone();\n\t\t\t\t\t\t\t    $helper.children().each(function(index) {\n\t\t\t\t\t\t\t    \t// Set helper cell sizes to match the original sizes\n\t\t\t\t\t\t\t    \t$(this).width($originals.eq(index).width());\n\t\t\t\t\t\t\t    });\n\t\t\t\t\t\t\t    return $helper.css({\n\t\t\t\t\t\t\t    \tbackground: '#EAEAEA'\n\t\t\t\t\t\t\t    });\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tupdate: function() { }\n\t\t\t\t\t\t});\n\n\t\t\t\t\t});\n\t\t\t\t}(jQuery));\n\t\t\t\t</script>\n\n\t\t\t\t<p>\n\t\t\t\t\t<a href=\"#\" id=\"podlove_add_new_rule\" class=\"button\"><?php _e('Add new rule', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t\t</p>\n\t\t\t\t<p class=\"description\">\n\t\t\t\t\t<?php _e('Create custom permanent redirects. URLs can be absolute like <code>http://example.com/feed</code> or relative to the website like <code>/feed</code>.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</p>\n\n\t\t\t\t<style type=\"text/css\">\n\t\t\t\t#podlove-redirects th.count,\n\t\t\t\t#podlove-redirects td.count,\n\t\t\t\t#podlove-redirects th.delete,\n\t\t\t\t#podlove-redirects td.delete,\n\t\t\t\t#podlove-redirects th.move,\n\t\t\t\t#podlove-redirects td.move {\n\t\t\t\t\twidth: 50px;\n\t\t\t\t\ttext-align: right;\n\t\t\t\t}\n\n\t\t\t\t#podlove-redirects th.count,\n\t\t\t\t#podlove-redirects td.count {\n\t\t\t\t\twidth: 100px;\n\t\t\t\t}\n\n\t\t\t\t#podlove-redirects th.delete,\n\t\t\t\t#podlove-redirects td.delete {\n\t\t\t\t\twidth: 65px;\n\t\t\t\t}\n\n\t\t\t\t#podlove-redirects td.count span {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\theight: 28px;\n\t\t\t\t\tline-height: 26px;\n\t\t\t\t\tpadding-top: 2px;\n\t\t\t\t\tpadding-bottom: 1px;\n\t\t\t\t\tpadding-right: 5px;\n\t\t\t\t}\n\n\t\t\t\t#podlove-redirects td input[type=\"text\"] {\n\t\t\t\t\twidth: 100%;\n\t\t\t\t}\n\n\t\t\t\t.form-table > tbody > tr > th {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t}\n\t\t\t\t</style>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_redirects'\n        );\n\n        register_setting(Settings::$pagehook, 'podlove_redirects');\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/tracking.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Geo_Ip;\nuse Podlove\\Model;\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Settings;\n\nclass Tracking extends Tab\n{\n    private const TOOLS_ERROR_GROUP = 'podlove_tracking_tools';\n\n    public function get_slug()\n    {\n        return 'tracking';\n    }\n\n    public function init()\n    {\n        add_settings_section(\n            // $id\n            'podlove_settings_episode',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Download Tracking & Analytics Settings', 'podlove-podcasting-plugin-for-wordpress').'</h3>'; ?>\n\t\t\t\t<style type=\"text/css\">\n\t\t\t\t.form-table .aligned-radio { display: table; margin-bottom: 10px; }\n\t\t\t\t.form-table .aligned-radio .row { display: table-row; }\n\t\t\t\t.form-table .aligned-radio .row > div { display: table-cell; }\n\t\t\t\t</style>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_tracking',\n            // $title\n            sprintf(\n                '<label for=\"mode\">%s</label>',\n                __('Tracking Mode', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\n\t\t\t\t<label class=\"aligned-radio\">\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input name=\"podlove_tracking[mode]\" type=\"radio\" value=\"ptm_analytics\" <?php checked(\\Podlove\\get_setting('tracking', 'mode'), 'ptm_analytics'); ?> />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t\t    '<div><strong>%s</strong><br>%s</div>',\n\t\t\t\t\t\t\t    __('Tracking URL Parameters &amp; Analytics', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t\t    __('Instead of the original file URLs, users and clients see a link that points to Podlove Publisher.\n\t\t\t\t\t\t\t\t\tPodlove Publisher logs the download intent and redirects the user to the original file.\n\t\t\t\t\t\t\t\t\tThat way Podlove Publisher is able to generate download statistics. ', 'podlove-podcasting-plugin-for-wordpress')\n\t\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n\n\t\t\t\t<label class=\"aligned-radio\">\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input name=\"podlove_tracking[mode]\" type=\"radio\" value=\"ptm\" <?php checked(\\Podlove\\get_setting('tracking', 'mode'), 'ptm'); ?> />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t\t    '<div><strong>%s</strong><br>%s</div>',\n\t\t\t\t\t\t\t    __('Tracking URL Parameters', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t\t    __('Original file URLs are extended by tracking parameters before presenting them to users and clients.\n\t\t\t\t\t\t\t\t\tThis is useful if you are using your server log files for download analytics.\n\t\t\t\t\t\t\t\t\tNo download-data is tracked.', 'podlove-podcasting-plugin-for-wordpress')\n\t\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n\n\t\t\t\t<label class=\"aligned-radio\">\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input name=\"podlove_tracking[mode]\" type=\"radio\" value=\"0\" <?php checked(\\Podlove\\get_setting('tracking', 'mode'), 0); ?> />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t\t    '<div><strong>%s</strong><br>%s</div>',\n\t\t\t\t\t\t\t    __('No Tracking', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t\t    __('Original file URLs are presented to users and clients. No download-data is tracked.', 'podlove-podcasting-plugin-for-wordpress')\n\t\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_tracking_window',\n            // $title\n            sprintf(\n                '<label for=\"mode\">%s</label>',\n                __('Deduplication Window', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\n\t\t\t\t<p class=\"description\" style=\"margin-bottom: 15px;\">\n\t\t\t\t  <?php echo sprintf(\n\t\t\t\t      __('A request counts as identical when the same IP and user agent are used to access the same file in a certain time window.\n\t\t\t\t\tPodlove Publisher has traditionally used an hourly time window but IAB recommends daily. Beware: Once changed you need to\n\t\t\t\t\tdo a full Download Intent Cleanup and Download Aggregation for the change to take effect. Do this at the %stools page%s.'),\n\t\t\t\t      '<a href=\"'.admin_url('admin.php?page=podlove_tools_settings_handle#the_tools_section').'\">',\n\t\t\t\t      '</a>'\n\t\t\t\t  ); ?>\n\t\t\t\t</p>\n\n\t\t\t\t<label class=\"aligned-radio\">\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input name=\"podlove_tracking[window]\" type=\"radio\" value=\"hourly\" <?php checked(\\Podlove\\get_setting('tracking', 'window'), 'hourly'); ?> />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t\t    '<div><strong>%s</strong><br>%s</div>',\n\t\t\t\t\t\t\t    __('Hour', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t\t    __('Identical requests during the same hour are counted once.', 'podlove-podcasting-plugin-for-wordpress')\n\t\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n\n\t\t\t\t<label class=\"aligned-radio\">\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input name=\"podlove_tracking[window]\" type=\"radio\" value=\"daily\" <?php checked(\\Podlove\\get_setting('tracking', 'window'), 'daily'); ?> />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t\t    '<div><strong>%s</strong><br>%s</div>',\n\t\t\t\t\t\t\t    __('Day', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t\t    __('Identical requests during the same day are counted once.', 'podlove-podcasting-plugin-for-wordpress')\n\t\t\t\t\t\t\t); ?>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_tracking_google_analytics',\n            // $title\n            sprintf(\n                '<label for=\"mode\">%s</label>',\n                __('Google Analytics Tracking ID', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\n\t\t\t\t<div>\n\t\t\t\t  <input class=\"large-text\" type=\"text\" name=\"podlove_tracking[ga]\" value=\"<?php echo \\Podlove\\get_setting('tracking', 'ga'); ?> \" />\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t<?php\n                echo __('Google Analytics Tracking ID. If entered, Podlove Publisher will log download intents to GA. Leave blank to deactivate GA reporting.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_tracking_matomo',\n            // $title\n            sprintf(\n                '<label for=\"mode\">%s</label>',\n                __('Matomo', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<div style=\"display: grid; gap: 12px;\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label for=\"podlove_tracking_matomo_url\"><strong><?php _e('Tracking URL', 'podlove-podcasting-plugin-for-wordpress'); ?></strong></label>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input id=\"podlove_tracking_matomo_url\" class=\"large-text\" type=\"text\" name=\"podlove_tracking[matomo_url]\" value=\"<?php echo \\Podlove\\get_setting('tracking', 'matomo_url'); ?>\" placeholder=\"https://your-subdomain.matomo.cloud/matomo.php\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"description\">\n\t\t\t\t\t\t\t<?php echo __('Usually the path to <code>matomo.php</code> on your Matomo instance. Leave blank to deactivate Matomo reporting.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label for=\"podlove_tracking_matomo_site_id\"><strong><?php _e('Site ID', 'podlove-podcasting-plugin-for-wordpress'); ?></strong></label>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input id=\"podlove_tracking_matomo_site_id\" class=\"regular-text\" type=\"text\" name=\"podlove_tracking[matomo_site_id]\" value=\"<?php echo \\Podlove\\get_setting('tracking', 'matomo_site_id'); ?>\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"description\">\n\t\t\t\t\t\t\t<?php echo __('The ID of the website you are tracking in Matomo.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label for=\"podlove_tracking_matomo_token\"><strong><?php _e('Auth Token', 'podlove-podcasting-plugin-for-wordpress'); ?></strong></label>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<input id=\"podlove_tracking_matomo_token\" class=\"large-text\" type=\"password\" name=\"podlove_tracking[matomo_token]\" value=\"<?php echo \\Podlove\\get_setting('tracking', 'matomo_token'); ?>\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"description\">\n\t\t\t\t\t\t\t<?php echo __('Matomo Auth Token (<code>token_auth</code>). Required to override visitor IP and User Agent. Leave blank if you don\\'t have one, but tracking will be less accurate.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_episode'\n        );\n\n        register_setting(\n            Settings::$pagehook,\n            'podlove_tracking',\n            function ($args) {\n                \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n\n                return $args;\n            }\n        );\n    }\n\n    public function page()\n    {\n        $this->handle_tools_post();\n\n        $screen_base = get_current_screen()->base;\n        ?>\n\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t<?php if (isset($_REQUEST['podlove_tab'])) { ?>\n\t\t\t\t<input type=\"hidden\" name=\"podlove_tab\" value=\"<?php echo esc_attr($_REQUEST['podlove_tab']); ?>\" />\n\t\t\t<?php } ?>\n\n\t\t\t<?php settings_fields($screen_base); ?>\n\t\t\t<?php do_settings_sections($screen_base); ?>\n\t\t\t<?php submit_button(__('Save Changes'), 'button-primary', 'submit', true); ?>\n\t\t</form>\n\n\t\t<?php settings_errors(self::TOOLS_ERROR_GROUP); ?>\n\n\t\t<div class=\"card podlove-settings-panel\" style=\"max-width: 100%\">\n\t\t\t<h3><?php _e('Maintenance', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t\t<?php $this->render_geolocation_panel(); ?>\n\t\t</div>\n\n\t\t<div class=\"card podlove-settings-panel\" style=\"max-width: 100%\">\n\t\t\t<h3><?php _e('Diagnostics', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t\t<?php $this->render_debug_panel(); ?>\n\t\t</div>\n\n\t\t<style type=\"text/css\">\n\t\t.podlove-settings-panel {\n\t\t\tmargin-top: 20px;\n\t\t}\n\t\t</style>\n\t\t<?php\n    }\n\n    private function handle_tools_post()\n    {\n        if (!isset($_POST['podlove_tracking_tool_action'])) {\n            return;\n        }\n\n        if (!current_user_can('administrator')) {\n            return;\n        }\n\n        check_admin_referer('podlove_tracking_tools_action');\n\n        if ($_POST['podlove_tracking_tool_action'] !== 'update_geo_database') {\n            return;\n        }\n\n        $result = \\Podlove\\Geo_Ip::update_database();\n\n        if (\\is_wp_error($result)) {\n            add_settings_error(self::TOOLS_ERROR_GROUP, 'podlove_tracking_tools_error', $result->get_error_message(), 'error');\n\n            return;\n        }\n\n        add_settings_error(\n            self::TOOLS_ERROR_GROUP,\n            'podlove_tracking_tools_updated',\n            __('Geolocation database updated.', 'podlove-podcasting-plugin-for-wordpress'),\n            'updated'\n        );\n    }\n\n    private function render_geolocation_panel()\n    {\n        $file = \\Podlove\\Geo_Ip::get_upload_file_path();\n        \\Podlove\\Geo_Ip::register_updater_cron();\n        ?>\n\t\t<p class=\"description\">\n\t\t\t<?php _e('Use this section to manage the GeoLite database used for geolocation lookups.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\n\t\t<?php if (!class_exists('PharData')) { ?>\n\t\t\t<p><?php echo __('Required PHP class <code>PharData</code> is missing.', 'podlove-podcasting-plugin-for-wordpress'); ?></p>\n\t\t<?php } elseif (file_exists($file)) { ?>\n\t\t\t<p>\n\t\t\t\t<?php echo __('Geolocation database', 'podlove-podcasting-plugin-for-wordpress'); ?>:\n\t\t\t\t<code><?php echo esc_html($file); ?></code>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php echo __('Last modified', 'podlove-podcasting-plugin-for-wordpress'); ?>:\n\t\t\t\t<?php echo esc_html(date(get_option('date_format').' '.get_option('time_format'), filemtime($file))); ?>\n\t\t\t</p>\n\t\t\t<p>\n\t\t\t\t<?php\n                $next_update = wp_next_scheduled('podlove_geoip_db_update');\n\n\t\t    echo sprintf(\n\t\t        __('The database is updated automatically once a month. Next scheduled update: %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t        $next_update ? esc_html(date(get_option('date_format').' '.get_option('time_format'), $next_update)) : __('not scheduled', 'podlove-podcasting-plugin-for-wordpress')\n\t\t    ); ?>\n\t\t\t</p>\n\t\t<?php } else { ?>\n\t\t\t<p>\n\t\t\t\t<?php echo __('You need to download a geolocation database for lookups to work.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t<?php } ?>\n\n\t\t<p>\n\t\t\t<?php echo sprintf(__('Geo-Tracking is <em>%s</em>.', 'podlove-podcasting-plugin-for-wordpress'), Geo_ip::is_enabled() ? __('active', 'podlove-podcasting-plugin-for-wordpress') : __('inactive', 'podlove-podcasting-plugin-for-wordpress')); ?>\n\t\t</p>\n\n\t\t<form method=\"post\" action=\"\">\n\t\t\t<?php wp_nonce_field('podlove_tracking_tools_action'); ?>\n\t\t\t<input type=\"hidden\" name=\"podlove_tracking_tool_action\" value=\"update_geo_database\" />\n\t\t\t<?php submit_button(file_exists($file) ? __('Update Now', 'podlove-podcasting-plugin-for-wordpress') : __('Download Now', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit', false); ?>\n\t\t</form>\n\n\t\t<p>\n\t\t\t<em>\n\t\t\t\t<?php echo sprintf(\n\t\t\t\t    __('This product includes GeoLite2 data created by MaxMind, available from %s.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t    '<a href=\"http://www.maxmind.com\">http://www.maxmind.com</a>'\n\t\t\t\t); ?>\n\t\t\t</em>\n\t\t</p>\n\t\t<?php\n    }\n\n    private function render_debug_panel()\n    {\n        if (!\\get_option('permalink_structure')) {\n            ?>\n\t\t\t<div class=\"notice notice-warning inline\">\n\t\t\t\t<p>\n\t\t\t\t\t<b><?php echo __('Please Change Permalink Structure', 'podlove-podcasting-plugin-for-wordpress'); ?></b>\n\t\t\t\t\t<?php\n                    echo sprintf(\n                        __('You are using the default WordPress permalink structure. This may cause problems with some podcast clients when you activate tracking. Go to %s and set it to anything but default (for example \"Post name\") before activating Tracking.', 'podlove-podcasting-plugin-for-wordpress'),\n                        '<a href=\"'.admin_url('options-permalink.php').'\">'.__('Permalink Settings').'</a>'\n                    ); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        }\n\n        $media_file = Model\\MediaFile::find_example();\n        if (!$media_file) {\n            echo '<p>'.__('No example media file is available for diagnostics.', 'podlove-podcasting-plugin-for-wordpress').'</p>';\n\n            return;\n        }\n\n        $episode = $media_file->episode();\n        if (!$episode) {\n            echo '<p>'.__('No example episode is available for diagnostics.', 'podlove-podcasting-plugin-for-wordpress').'</p>';\n\n            return;\n        }\n\n        $public_url = $media_file->get_public_file_url('debug');\n        $actual_url = $media_file->get_file_url();\n        ?>\n\t\t<p class=\"description\">\n\t\t\t<?php _e('Diagnostics are read-only checks for the current tracking setup.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<h4><?php _e('Example Episode', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t<p><?php echo esc_html($episode->full_title()); ?></p>\n\t\t<h4><?php _e('Media File', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t\t<div>\n\t\t\t<h5><?php _e('Actual Location', 'podlove-podcasting-plugin-for-wordpress'); ?></h5>\n\t\t\t<code><?php echo esc_html($actual_url); ?></code>\n\t\t</div>\n\t\t<div>\n\t\t\t<h5><?php _e('Public URL', 'podlove-podcasting-plugin-for-wordpress'); ?></h5>\n\t\t\t<code><?php echo esc_html($public_url); ?></code>\n\t\t</div>\n\t\t<div>\n\t\t\t<h5><?php _e('Validations', 'podlove-podcasting-plugin-for-wordpress'); ?></h5>\n\t\t\t<ul>\n\t\t\t\t<li>\n\t\t\t\t\t<?php if (\\Podlove\\Tracking\\Debug::rewrites_exist()) { ?>\n\t\t\t\t\t\t✔ <?php _e('Rewrite Rules Exist', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<?php } else { ?>\n\t\t\t\t\t\t✘ <strong><?php _e('Rewrite Rules Missing', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<?php if (\\Podlove\\Tracking\\Debug::url_resolves_correctly($public_url, $actual_url)) { ?>\n\t\t\t\t\t\t✔ <?php _e('URL resolves correctly', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<?php } else { ?>\n\t\t\t\t\t\t✘ <strong><?php _e('URL does not resolve correctly', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t\t\t<?php if (stristr($actual_url, 'https') !== false && \\Podlove\\get_setting('website', 'ssl_verify_peer') == 'on') { ?>\n\t\t\t\t\t\t\t<em><?php echo sprintf(__('The cause might be a server specific SSL misconfiguration. To work around this, disable \"Check for Assets with SSL-peer-verification\" in %sExpert Settings%s or ask your admin/hoster for help.', 'podlove-podcasting-plugin-for-wordpress'), '<a href=\"'.admin_url('admin.php?page=podlove_settings_settings_handle').'\" target=\"_blank\">', '</a>'); ?></em>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<?php if (\\Podlove\\Tracking\\Debug::is_consistent_https_chain($public_url, $actual_url)) { ?>\n\t\t\t\t\t\t✔ <?php _e('Consistent protocol chain', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<?php } else { ?>\n\t\t\t\t\t\t✘ <strong><?php _e('Protocol chain is inconsistent', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>: <?php _e('Your site uses SSL but the files are not served with SSL. Many clients will not allow to download episodes. To fix this, serve files via SSL or deactivate tracking.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<?php if (Geo_Ip::is_db_valid()) { ?>\n\t\t\t\t\t\t✔ <?php _e('Geolocation database valid', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t<?php Geo_Ip::enable_tracking(); ?>\n\t\t\t\t\t<?php } else { ?>\n\t\t\t\t\t\t<?php Geo_Ip::disable_tracking(); ?>\n\t\t\t\t\t\t✘ <strong><?php _e('Geolocation database invalid or outdated', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>:\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('Try updating it using the button above. If that doesn\\'t work, delete it manually: %s, then redownload it in the section above. If that fails, you can download it with your web browser, unzip it, and upload it to WordPress using sFTP: %s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    '<code>'.esc_html(Geo_Ip::get_upload_file_path()).'</code>',\n\t\t\t\t\t\t    '<a href=\"'.esc_url(Geo_Ip::SOURCE_URL).'\" download>'.esc_html(Geo_Ip::SOURCE_URL).'</a>'\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/web_player.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Model;\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Settings;\n\nclass WebPlayer extends Tab\n{\n    public function get_slug()\n    {\n        return 'web-player';\n    }\n\n    public function init()\n    {\n        add_settings_section(\n            // $id\n            'podlove_settings_episode',\n            // $title\n            '',\n            // $callback\n            function () {\n                echo '<h3>'.__('Web Player Settings', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        register_setting(Settings::$pagehook, 'podlove_webplayer_formats');\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t<?php if (isset($_REQUEST['podlove_tab'])) { ?>\n\t\t\t\t<input type=\"hidden\" name=\"podlove_tab\" value=\"<?php echo esc_attr($_REQUEST['podlove_tab']); ?>\" />\n\t\t\t<?php } ?>\n\t\t\t<?php settings_fields(Settings::$pagehook); ?>\n\t\t\t<?php do_settings_sections(Settings::$pagehook); ?>\n\n\t\t\t<?php _e('Web players are able to provide various media formats depending on context. Try to provide as many as possible to maximize compatibility with all browsers.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\n\t\t\t<table class=\"form-table\">\n\t\t\t\t<?php $this->form_fields(); ?>\n\t\t\t</table>\n\n\t\t\t<?php submit_button(__('Save Changes'), 'button-primary', 'submit', true); ?>\n\t\t</form>\n\t\t<?php\n    }\n\n    /**\n     * Config array containing list of supported web player assets.\n     * Each type (audio, video) lists supported extensions with their title and mime_type.\n     *\n     * @return array\n     */\n    public static function formats()\n    {\n        return [\n            'audio' => [\n                'mp3' => [\n                    'title' => __('MP3 Audio', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['audio/mpeg'],\n                ],\n                'mp4' => [\n                    'title' => __('MP4 Audio', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['audio/mp4'],\n                ],\n                'ogg' => [\n                    'title' => __('OGG Audio', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['audio/ogg'],\n                ],\n                'opus' => [\n                    'title' => __('Opus Audio', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['audio/ogg;codecs=opus', 'audio/opus'],\n                ],\n            ],\n            'video' => [\n                'mp4' => [\n                    'title' => __('MP4 Video', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['video/mp4'],\n                ],\n                'ogg' => [\n                    'title' => __('OGG Video', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['video/ogg'],\n                ],\n                'webm' => [\n                    'title' => __('WebM Video', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['video/webm'],\n                ],\n            ],\n            'transcript' => [\n                'transcript' => [\n                    'title' => __('Transcript (Only Podigee Player)', 'podlove-podcasting-plugin-for-wordpress'),\n                    'mime_types' => ['text/plain', 'application/x-subrip'],\n                ],\n            ],\n        ];\n    }\n\n    public function form_fields()\n    {\n        $formats_data = get_option('podlove_webplayer_formats', []);\n        $episode_assets = Model\\EpisodeAsset::all();\n\n        foreach (self::formats() as $format => $extensions) {\n            ?>\n\t\t\t<tr valign=\"top\">\n\t\t\t\t<th scope=\"row\" valign=\"top\" colspan=\"2\">\n\t\t\t\t\t<h3><?php echo ucfirst($format); ?></h3>\n\t\t\t\t</th>\n\t\t\t</tr>\n\t\t\t<?php\n            foreach ($extensions as $extension => $extension_data) {\n                $label = $extension_data['title'];\n                $mime_types = $extension_data['mime_types'];\n\n                $id = sprintf('podlove_webplayer_formats_%s_%s', $format, $extension);\n                $name = sprintf('podlove_webplayer_formats[%s][%s]', $format, $extension);\n                $value = (isset($formats_data[$format], $formats_data[$format][$extension])) ? $formats_data[$format][$extension] : 0; ?>\n\t\t\t\t<tr valign=\"top\">\n\t\t\t\t\t<th scope=\"row\" valign=\"top\">\n\t\t\t\t\t\t<label for=\"<?php echo $id; ?>\"><?php echo $label; ?></label>\n\t\t\t\t\t</th>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<select name=\"<?php echo $name; ?>\" id=\"<?php echo $id; ?>\">\n\t\t\t\t\t\t\t\t<option value=\"0\" <?php selected(0, $value); ?> ><?php _e('Unused', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<?php foreach ($episode_assets as $episode_asset) { ?>\n\t\t\t\t\t\t\t\t\t<?php $file_type = $episode_asset->file_type(); ?>\n\t\t\t\t\t\t\t\t\t<?php if ($file_type && in_array($file_type->mime_type, $mime_types)) { ?>\n\t\t\t\t\t\t\t\t\t\t<option value=\"<?php echo $episode_asset->id; ?>\" <?php selected($episode_asset->id, $value); ?>><?php echo esc_html($episode_asset->title); ?></option>\n\t\t\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<?php\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab/website.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert\\Tab;\n\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Settings;\n\nclass Website extends Tab\n{\n    public function get_slug()\n    {\n        return 'website';\n    }\n\n    public function init()\n    {\n        // always flush rewrite rules here for custom_episode_slug setting\n        add_action('update_option_podlove_website', function ($old, $new) {\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n        }, 10, 2);\n\n        add_settings_section(\n            // $id\n            'podlove_settings_general',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Website Settings', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_files',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Files & Downloads', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_section(\n            // $id\n            'podlove_settings_feeds',\n            // $title\n            __('', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () {\n                echo '<h3>'.__('Feeds', 'podlove-podcasting-plugin-for-wordpress').'</h3>';\n            },\n            // $page\n            Settings::$pagehook\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_merge_episodes',\n            // $title\n            sprintf(\n                '<label for=\"merge_episodes\">%s</label>',\n                __('Combine blog & podcast', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_website[merge_episodes]\" id=\"merge_episodes\" type=\"checkbox\" <?php checked(\\Podlove\\get_setting('website', 'merge_episodes'), 'on'); ?>>\n\t\t\t\t<?php\n                echo __('Include episode posts on the front page and in the blog feed', 'podlove-podcasting-plugin-for-wordpress');\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_hide_wp_feed_discovery',\n            // $title\n            sprintf(\n                '<label for=\"hide_wp_feed_discovery\">%s</label>',\n                __('Hide blog feeds', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_website[hide_wp_feed_discovery]\" id=\"hide_wp_feed_discovery\" type=\"checkbox\" <?php checked(\\Podlove\\get_setting('website', 'hide_wp_feed_discovery'), 'on'); ?>>\n\t\t\t\t<?php\n                echo __('Hide default WordPress feeds for blog and comments (no auto-discovery).', 'podlove-podcasting-plugin-for-wordpress');\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_custom_episode_slug',\n            // $title\n            sprintf(\n                '<label for=\"custom_episode_slug\">%s</label>',\n                __('Permalink structure for episodes', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $use_post_permastruct = \\Podlove\\get_setting('website', 'use_post_permastruct');\n                $custom_episode_slug = \\Podlove\\get_setting('website', 'custom_episode_slug');\n\n                if ($blog_prefix = \\Podlove\\get_blog_prefix()) {\n                    $custom_episode_slug = preg_replace('|^/?blog|', '', $custom_episode_slug);\n                }\n\n                \\Podlove\\load_template('expert_settings/website/custom_episode_slug', compact('use_post_permastruct', 'custom_episode_slug'));\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_episode_archive',\n            // $title\n            sprintf(\n                '<label for=\"episode_archive\">%s</label>',\n                __('Episode pages', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $enable_episode_archive = \\Podlove\\get_setting('website', 'episode_archive');\n                $episode_archive_slug = \\Podlove\\get_setting('website', 'episode_archive_slug');\n\n                if ($blog_prefix = \\Podlove\\get_blog_prefix()) {\n                    $episode_archive_slug = preg_replace('|^/?blog|', '', $episode_archive_slug);\n                }\n\n                \\Podlove\\load_template('expert_settings/website/episode_archive', compact('enable_episode_archive', 'episode_archive_slug', 'blog_prefix'));\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_landing_page',\n            // $title\n            sprintf(\n                '<label for=\"landing_page\">%s</label>',\n                __('Podcast landing page', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $landing_page = \\Podlove\\get_setting('website', 'landing_page');\n\n                $landing_page_options = [\n                    ['value' => 'homepage', 'text' => __('Front page', 'podlove-podcasting-plugin-for-wordpress')],\n                    ['value' => 'archive', 'text' => __('Episode pages', 'podlove-podcasting-plugin-for-wordpress')],\n                    ['text' => '––––––––––', 'disabled' => true],\n                ];\n\n                $pages_query = new \\WP_Query([\n                    'post_type' => 'page',\n                    'nopaging' => true,\n                ]);\n\n                if ($pages_query->have_posts()) {\n                    while ($pages_query->have_posts()) {\n                        $pages_query->the_post();\n                        $landing_page_options[] = ['value' => get_the_ID(), 'text' => get_the_title()];\n                    }\n                }\n\n                wp_reset_postdata();\n\n                \\Podlove\\load_template('expert_settings/website/landing_page', compact('landing_page', 'landing_page_options'));\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_blog_post_title',\n            // $title\n            sprintf(\n                '<label for=\"enable_generated_blog_post_title\">%s</label>',\n                __('Blog Episode Titles', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $enable_generated_blog_post_title = \\Podlove\\get_setting('website', 'enable_generated_blog_post_title');\n                $blog_title_template = \\Podlove\\get_setting('website', 'blog_title_template');\n\n                \\Podlove\\load_template('expert_settings/website/blog_post_title', compact('enable_generated_blog_post_title', 'blog_title_template'));\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_episode_number_padding',\n            // $title\n            sprintf(\n                '<label for=\"episode_number_padding\">%s</label>',\n                __('Episode Number Padding', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $episode_number_padding = \\Podlove\\get_setting('website', 'episode_number_padding'); ?>\n\t\t\t\t<input type=\"number\" name=\"podlove_website[episode_number_padding]\" value=\"<?php echo $episode_number_padding; ?>\" id=\"episode_number_padding\" class=\"large-text\" style=\"max-width: 66px\" />\n\t\t\t\t<p>\n\t\t\t\t\t<span class=\"description\">\n\t\t\t\t\t\t<?php echo __('Preferred episode number length. If an episode number is smaller than desired, it will be prefixed with zeroes. For example, episode number 1 with a padding of 3 will be printed as 001.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t</span>\n\t\t\t\t</p>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_general'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_url_template',\n            // $title\n            sprintf(\n                '<label for=\"url_template\">%s</label>',\n                __('Episode Asset URL Template.', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                $current_template = \\Podlove\\get_setting('website', 'url_template');\n\n                $field_config = apply_filters('podlove_url_template_field_config', [\n                    'attributes' => 'class=\"large-text podlove-check-input\"',\n                    'description' => __('Is used to generate URLs. You probably don\\'t want to change this.', 'podlove-podcasting-plugin-for-wordpress')\n                ]);\n                ?>\n                <input name=\"podlove_website[url_template]\" id=\"url_template\" type=\"text\" value=\"<?php echo esc_attr($current_template); ?>\" <?php echo $field_config['attributes']; ?>>\n                <p>\n                    <span class=\"description\">\n                        <?php echo $field_config['description']; ?>\n                    </span>\n                </p>\n                <?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_files'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_ssl_verify_peer',\n            // $title\n            sprintf(\n                '<label for=\"ssl_verify_peer\">%s</label>',\n                __('Check for Assets with SSL-peer-verification.', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_website[ssl_verify_peer]\" id=\"ssl_verify_peer\" type=\"checkbox\" <?php checked(\\Podlove\\get_setting('website', 'ssl_verify_peer'), 'on'); ?>>\n\t\t\t\t<?php echo __('If you provide your assets via https with a self-signed or not verifiable SSL-certificate, podlove should display your assets as non exiting. You might solve this by deactivating the ssl peer verification for asset checking. (Detailed: This sets \"CURLOPT_SSL_VERIFYPEER\" to FALSE.)', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_files'\n        );\n\n        add_settings_field(\n            // $id\n            'podlove_setting_feeds_skip_redirect',\n            // $title\n            sprintf(\n                '<label for=\"feeds_skip_redirect\">%s</label>',\n                __('Allow to skip feed redirects', 'podlove-podcasting-plugin-for-wordpress')\n            ),\n            // $callback\n            function () {\n                ?>\n\t\t\t\t<input name=\"podlove_website[feeds_skip_redirect]\" id=\"feeds_skip_redirect\" type=\"checkbox\" <?php checked(\\Podlove\\get_setting('website', 'feeds_skip_redirect'), 'on'); ?>>\n\t\t\t\t<?php echo __('If you need to debug you feeds while using a feed proxy, add <code>?redirect=no</code> to the feed URL to skip the redirect.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t<?php\n            },\n            // $page\n            Settings::$pagehook,\n            // $section\n            'podlove_settings_feeds'\n        );\n\n        register_setting(Settings::$pagehook, 'podlove_website', function ($options) {\n            /**\n             * handle checkboxes.\n             */\n            $checkboxes = [\n                'merge_episodes',\n                'hide_wp_feed_discovery',\n                'use_post_permastruct',\n                'episode_archive',\n                'ssl_verify_peer',\n                'feeds_skip_redirect',\n            ];\n            foreach ($checkboxes as $checkbox_key) {\n                if (!isset($options[$checkbox_key])) {\n                    $options[$checkbox_key] = 'off';\n                }\n            }\n\n            /**\n             * handle permastructs.\n             */\n            $prefix = $blog_prefix = '';\n            $iis7_permalinks = iis7_supports_permalinks();\n\n            if (!got_mod_rewrite() && !$iis7_permalinks) {\n                $prefix = '/index.php';\n            }\n\n            if (is_multisite() && !is_subdomain_install() && is_main_site()) {\n                $blog_prefix = '';\n            }\n\n            // Episode permastruct\n            if (array_key_exists('custom_episode_slug', $options)) {\n                $options['custom_episode_slug'] = preg_replace('#/+#', '/', '/'.str_replace('#', '', $options['custom_episode_slug']));\n\n                if ($prefix && $blog_prefix) {\n                    $options['custom_episode_slug'] = $prefix.preg_replace('#^/?index\\.php#', '', $options['custom_episode_slug']);\n                } else {\n                    $options['custom_episode_slug'] = $blog_prefix.$options['custom_episode_slug'];\n                }\n            }\n\n            // Archive slug\n            if (array_key_exists('episode_archive_slug', $options)) {\n                $options['episode_archive_slug'] = preg_replace('#/+#', '/', '/'.str_replace('#', '', $options['episode_archive_slug']));\n\n                if ($prefix && $blog_prefix) {\n                    $options['episode_archive_slug'] = $prefix.preg_replace('#^/?index\\.php#', '', $options['episode_archive_slug']);\n                } else {\n                    $options['episode_archive_slug'] = $blog_prefix.$options['episode_archive_slug'];\n                }\n            }\n\n            return $options;\n        });\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tab.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert;\n\nuse Podlove\\Settings\\Settings;\n\n/**\n * Represents one Expert Settings Tab.\n */\nclass Tab\n{\n    protected $page_type = 'settings api';\n\n    /**\n     * Tab title.\n     *\n     * @var string\n     */\n    private $title;\n\n    /**\n     * If this is true, use it if no tab is selected.\n     *\n     * @var bool\n     */\n    private $is_default;\n\n    public function __construct($title, $is_default = false)\n    {\n        $this->set_title($title);\n        $this->is_default = $is_default;\n    }\n\n    public function is_active()\n    {\n        $is_current_tab = isset($_REQUEST['podlove_tab']) && $this->get_slug() == $_REQUEST['podlove_tab'];\n\n        return $is_current_tab || !isset($_REQUEST['podlove_tab']) && $this->is_default;\n    }\n\n    public function get_title()\n    {\n        return $this->title;\n    }\n\n    public function set_title($title)\n    {\n        $this->title = $title;\n    }\n\n    public function get_slug()\n    {\n        return '';\n    }\n\n    public function get_url()\n    {\n        return sprintf('?page=%s&podlove_tab=%s', htmlspecialchars($_REQUEST['page'] ?? ''), $this->get_slug());\n    }\n\n    public function page()\n    {\n        if ($this->page_type == 'settings api') {\n            $screen_base = get_current_screen()->base; ?>\n\t\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t\t<?php if (isset($_REQUEST['podlove_tab'])) { ?>\n\t\t\t\t\t<input type=\"hidden\" name=\"podlove_tab\" value=\"<?php echo esc_attr($_REQUEST['podlove_tab']); ?>\" />\n\t\t\t\t<?php } ?>\n\n\t\t\t\t<?php settings_fields($screen_base); ?>\n\t\t\t\t<?php do_settings_sections($screen_base); ?>\n\n\t\t\t\t<?php submit_button(__('Save Changes'), 'button-primary', 'submit', true); ?>\n\t\t\t</form>\n\t\t\t<?php\n        } else {\n            do_action('podlove_expert_settings_page');\n        }\n    }\n\n    public function init()\n    {\n        throw Exception('You need to subclass Tab and implement Tab::init');\n    }\n}\n"
  },
  {
    "path": "lib/settings/expert/tabs.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Expert;\n\n/**\n * Manages Expert Settings Tabs.\n */\nclass Tabs\n{\n    /**\n     * Tab Bar Title.\n     *\n     * @var string\n     */\n    private $title = '';\n\n    /**\n     * List of tab objects.\n     *\n     * @var array\n     */\n    private $tabs = [];\n\n    public function __construct($title)\n    {\n        $this->title = $title;\n    }\n\n    public function addTab($tab)\n    {\n        $this->tabs[] = $tab;\n    }\n\n    public function removeTab($slug)\n    {\n        foreach ($this->tabs as $index => $tab) {\n            if ($tab->get_slug() === $slug) {\n                unset($this->tabs[$index]);\n\n                break;\n            }\n        }\n    }\n\n    public function getTabsHTML()\n    {\n        $html = '<h2 class=\"nav-tab-wrapper\">';\n        $html .= '<span class=\"nav-tab-title\">'.$this->title.\"</span>\\n\";\n        foreach ($this->tabs as $tab) {\n            $html .= sprintf(\n                '<a href=\"%s\" class=\"nav-tab%s\">%s</a>',\n                $tab->get_url(),\n                $tab->is_active() ? ' nav-tab-active' : '',\n                $tab->get_title()\n            );\n        }\n        $html .= '</h2>';\n\n        return $html;\n    }\n\n    public function getCurrentTabPage()\n    {\n        if (is_object($this->getCurrentTab())) {\n            return $this->getCurrentTab()->page();\n        }\n    }\n\n    public function initCurrentTab()\n    {\n        if (is_object($this->getCurrentTab())) {\n            return $this->getCurrentTab()->init();\n        }\n    }\n\n    public function initAllTabs()\n    {\n        foreach ($this->tabs as $tab) {\n            $tab->init();\n        }\n    }\n\n    public function getTabs()\n    {\n        return $this->tabs;\n    }\n\n    private function getCurrentTab()\n    {\n        foreach ($this->tabs as $tab) {\n            if ($tab->is_active()) {\n                return $tab;\n            }\n        }\n\n        return $this->tabs[0];\n    }\n}\n"
  },
  {
    "path": "lib/settings/feed.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Model;\nuse Podlove\\Modules\\Plus\\FeedProxy;\n\nclass Feed\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public const MENU_SLUG = 'podlove_feeds_settings_handle';\n\n    public static $pagehook;\n\n    private $table;\n\n    private static $nonce = 'update_feeds';\n\n    public function __construct($handle)\n    {\n        self::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Podcast Feeds', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Podcast Feeds', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            self::MENU_SLUG,\n            // $function\n            [$this, 'page']\n        );\n        add_action('admin_init', [$this, 'process_form']);\n        add_action('load-'.self::$pagehook, [$this, 'add_screen_options']);\n\n        $this->init_page_documentation(self::$pagehook);\n\n        if (isset($_GET['page']) && $_GET['page'] == 'podlove_feeds_settings_handle' && isset($_GET['update_settings']) && $_GET['update_settings'] == 'true') {\n            add_action('admin_bar_init', [$this, 'save_global_feed_setting']);\n        }\n    }\n\n    public function add_screen_options()\n    {\n        add_screen_option('per_page', [\n            'label' => __('Feeds', 'podlove-podcasting-plugin-for-wordpress'),\n            'default' => 10,\n            'option' => 'podlove_feeds_per_page',\n        ]);\n\n        $this->table = new \\Podlove\\Feed_List_Table();\n    }\n\n    public static function get_action_link($feed, $title, $action = 'edit', $class = 'link')\n    {\n        return sprintf(\n            '<a href=\"?page=%s&action=%s&feed=%s&_podlove_nonce=%s\" class=\"%s\">'.esc_html($title).'</a>',\n            self::MENU_SLUG,\n            $action,\n            $feed->id,\n            wp_create_nonce('update_feeds'),\n            $class\n        );\n    }\n\n    public function process_form()\n    {\n        if (!isset($_REQUEST['feed'])) {\n            return;\n        }\n\n        $action = (isset($_REQUEST['action'])) ? $_REQUEST['action'] : null;\n\n        if (!in_array($action, ['save', 'create', 'delete'])) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        do_action('podlove_feed_process', $_REQUEST['feed'], $_REQUEST['action']);\n\n        set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n        if ($action === 'save') {\n            $this->save();\n        } elseif ($action === 'create') {\n            $this->create();\n        } elseif ($action === 'delete') {\n            $this->delete();\n        }\n    }\n\n    public function page()\n    {\n        $action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n\n        if ($action == 'confirm_delete' && isset($_REQUEST['feed'])) {\n            $feed = \\Podlove\\Model\\Feed::find_by_id((int) $_REQUEST['feed']); ?>\n\t\t\t<div class=\"updated\">\n\t\t\t\t<p>\n\t\t\t\t\t<strong>\n\t\t\t\t\t\t<?php echo sprintf(__('You selected to delete the feed \"%s\". Please confirm this action.', 'podlove-podcasting-plugin-for-wordpress'), esc_html($feed->name)); ?>\n\t\t\t\t\t</strong>\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t<?php echo __('Clients subscribing to this feed will no longer receive updates. If you are moving your feed, you must inform your subscribers.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t<?php echo self::get_action_link($feed, __('Delete feed permanently', 'podlove-podcasting-plugin-for-wordpress'), 'delete', 'button'); ?>\n\t\t\t\t\t<?php echo self::get_action_link($feed, __('Don\\'t change anything', 'podlove-podcasting-plugin-for-wordpress'), 'keep', 'button-primary'); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        } ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Podcast Feeds', 'podlove-podcasting-plugin-for-wordpress'); ?> <a href=\"?page=<?php echo self::MENU_SLUG; ?>&amp;action=new\" class=\"add-new-h2\"><?php echo __('Add New', 'podlove-podcasting-plugin-for-wordpress'); ?></a></h2>\n\t\t\t<?php\n\n            switch ($action) {\n                case 'new':   $this->new_template();\n\n                    break;\n                case 'edit':  $this->edit_template();\n\n                    break;\n                case 'index': $this->view_template();\n\n                    break;\n\n                default:      $this->view_template();\n\n                    break;\n            } ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    public function save_global_feed_setting()\n    {\n        $podcast_settings = get_option('podlove_podcast', []);\n\n        $args = filter_var_array($_REQUEST['podlove_podcast'], [\n            'limit_items' => FILTER_SANITIZE_NUMBER_INT,\n            'feed_episode_title_variant' => FILTER_UNSAFE_RAW,\n            'feed_episode_title_template' => FILTER_UNSAFE_RAW,\n            'feed_transcripts' => FILTER_UNSAFE_RAW,\n        ]);\n\n        $podcast_settings['limit_items'] = $args['limit_items'];\n        $podcast_settings['feed_episode_title_variant'] = $args['feed_episode_title_variant'];\n        $podcast_settings['feed_episode_title_template'] = $args['feed_episode_title_template'];\n        $podcast_settings['feed_transcripts'] = $args['feed_transcripts'];\n\n        update_option('podlove_podcast', $podcast_settings);\n        \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n        header('Location: '.get_site_url().'/wp-admin/admin.php?page=podlove_feeds_settings_handle');\n    }\n\n    /**\n     * Process form: save/update a format.\n     */\n    private function save()\n    {\n        if (!isset($_REQUEST['feed'])) {\n            return;\n        }\n\n        $feed = \\Podlove\\Model\\Feed::find_by_id($_REQUEST['feed']);\n        $feed->update_attributes($_POST['podlove_feed']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $feed->id);\n        } else {\n            $this->redirect('index', $feed->id);\n        }\n    }\n\n    /**\n     * Process form: create a format.\n     */\n    private function create()\n    {\n        global $wpdb;\n\n        $feed = new \\Podlove\\Model\\Feed();\n        $feed->update_attributes($_POST['podlove_feed']);\n\n        if (isset($_POST['submit_and_stay'])) {\n            $this->redirect('edit', $feed->id);\n        } else {\n            $this->redirect('index');\n        }\n    }\n\n    /**\n     * Process form: delete a format.\n     */\n    private function delete()\n    {\n        if (!isset($_REQUEST['feed'])) {\n            return;\n        }\n\n        \\Podlove\\Model\\Feed::find_by_id($_REQUEST['feed'])->delete();\n\n        $this->redirect('index');\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $feed_id\n     */\n    private function redirect($action, $feed_id = null)\n    {\n        $page = 'admin.php?page='.self::MENU_SLUG;\n        $show = ($feed_id) ? '&feed='.$feed_id : '';\n        $action = '&action='.$action;\n\n        wp_redirect(admin_url($page.$show.$action));\n        exit;\n    }\n\n    private function new_template()\n    {\n        $feed = new \\Podlove\\Model\\Feed(); ?>\n\t\t<h3><?php echo __('Add New Feed', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\t\t<?php\n        $this->form_template($feed, 'create', __('Add New Feed', 'podlove-podcasting-plugin-for-wordpress'));\n    }\n\n    private function view_template()\n    {\n        $this->validate_feeds();\n\n        $this->table->prepare_items();\n        $this->table->display();\n\n        do_action('podlove_before_feed_global_settings');\n\n        $this->global_feed_settings_form();\n\n        do_action('podlove_after_feed_global_settings');\n    }\n\n    /**\n     * Validate Feeds and show appropriate error messages.\n     */\n    private function validate_feeds()\n    {\n        $errors = [];\n\n        // check for missing mandatory fields\n        foreach (Model\\Feed::all() as $feed) {\n            if (!strlen(trim($feed->slug))) {\n                $errors[] = sprintf(\n                    __('The feed %s has no slug.', 'podlove-podcasting-plugin-for-wordpress'),\n                    '<strong>'.esc_html($feed->name).'</strong>'\n                )\n                          .\\Podlove\\get_help_link('podlove_help_feed_slug')\n                          .' '.self::get_action_link($feed, __('Go fix it', 'podlove-podcasting-plugin-for-wordpress'));\n            }\n            if (!$feed->episode_asset_id) {\n                $errors[] = sprintf(\n                    __('The feed %s has no assigned asset.', 'podlove-podcasting-plugin-for-wordpress'),\n                    '<strong>'.esc_html($feed->name).'</strong>'\n                )\n                          .\\Podlove\\get_help_link('podlove_help_feed_asset')\n                          .' '.self::get_action_link($feed, __('Go fix it', 'podlove-podcasting-plugin-for-wordpress'));\n            }\n        }\n\n        // check for duplicate slugs\n        foreach (Model\\Feed::find_duplicate_slugs() as $duplicate) {\n            $feeds = array_map(function ($feed_id) {\n                return Model\\Feed::find_by_id($feed_id);\n            }, $duplicate['feed_ids']);\n\n            $feed_links = array_map(function ($feed) {\n                return self::get_action_link($feed, $feed->name);\n            }, $feeds);\n\n            $errors[] = sprintf(\n                __('Some feeds (%s) use identical slugs. Please assign unique slugs.'),\n                implode(', ', $feed_links)\n            ).\\Podlove\\get_help_link('podlove_help_feed_slug');\n        }\n\n        if (count($errors)) {\n            ?>\n\t\t\t<div class=\"error\">\n\t\t\t\t<p>\n\t\t\t\t\t<strong><?php echo __('Please resolve these issues so your feeds can work.', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t<?php echo implode('</p><p>', $errors); ?>\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t\t<?php\n        }\n    }\n\n    private function global_feed_settings_form()\n    {\n        ?>\n\t\t<div class=\"podlove-form-card\">\n\t\t<form method=\"post\" action=\"admin.php?page=podlove_feeds_settings_handle&amp;update_settings=true\">\n\t\t\t<?php settings_fields(Podcast::$pagehook); ?>\n\n\t\t\t<?php\n            $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'form' => false,\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $wrapper->subheader(__('Feed Global Defaults', 'podlove-podcasting-plugin-for-wordpress'));\n\n            $limit_options = [\n                '-1' => __('No limit. Include all items.', 'podlove-podcasting-plugin-for-wordpress'),\n                '0' => __('Use WordPress Default', 'podlove-podcasting-plugin-for-wordpress').' ('.get_option('posts_per_rss').')',\n                '1' => 1,\n            ];\n            for ($i = 1; $i * 5 <= 100; ++$i) {\n                $limit_options[$i * 5] = $i * 5;\n            }\n\n            $limit_options = apply_filters('podlove_feed_limit_options', $limit_options);\n\n            $wrapper->select('limit_items', [\n                'label' => __('Limit Items', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('If you have a lot of episodes, you might want to restrict the feed size. Additional limits can be set for the feeds individually.', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $limit_options,\n                'please_choose' => false,\n                'default' => '-1',\n            ]);\n\n            $wrapper->select('feed_episode_title_variant', [\n                'label' => __('Episode Title', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('How should the episode title appear in the feed?', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => [\n                    'blog' => __('Blog Post Title', 'podlove-podcasting-plugin-for-wordpress'),\n                    'episode' => __('Episode Title', 'podlove-podcasting-plugin-for-wordpress'),\n                    'template' => __('Custom Template', 'podlove-podcasting-plugin-for-wordpress'),\n                ],\n                'please_choose' => false,\n                'default' => 'blog',\n            ]);\n\n            $wrapper->string('feed_episode_title_template', [\n                'label' => __('Episode Title Template', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('If you chose in the template option above, define the template here. Placeholders: ', 'podlove-podcasting-plugin-for-wordpress').'%mnemonic%, %episode_number%, %season_number%, %episode_title%',\n                'html' => ['class' => 'regular-text required'],\n                'default' => '%mnemonic%%episode_number% %episode_title%',\n            ]);\n\n            do_action('podlove_feeds_global_form', $wrapper);\n        }); ?>\n\t\t</form>\n\t\t</div>\n\t\t<?php\n    }\n\n    private function form_template($feed, $action, $button_text = null)\n    {\n        $form_args = [\n            'context' => 'podlove_feed',\n            'hidden' => [\n                'feed' => $feed->id,\n                'action' => $action,\n            ],\n            'submit_button' => false, // for custom control in form_end\n            'form_end' => function () {\n                echo '<p>';\n                submit_button(__('Save Changes'), 'primary', 'submit', false);\n                echo ' ';\n                submit_button(__('Save Changes and Continue Editing', 'podlove-podcasting-plugin-for-wordpress'), 'secondary', 'submit_and_stay', false);\n                echo '</p>';\n            },\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($feed, $form_args, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $feed = $form->object;\n\n            $podcast = \\Podlove\\Model\\Podcast::get();\n\n            $episode_assets = \\Podlove\\Model\\EpisodeAsset::all();\n            $assets = [];\n            foreach ($episode_assets as $asset) {\n                $assets[$asset->id] = esc_html($asset->title);\n            }\n\n            $wrapper->subheader(__('Basic Settings', 'podlove-podcasting-plugin-for-wordpress'));\n\n            $wrapper->select('episode_asset_id', [\n                'label' => __('Episode Media File', 'podlove-podcasting-plugin-for-wordpress').\\Podlove\\get_help_link('podlove_help_feed_asset'),\n                'options' => $assets,\n                'html' => ['class' => 'required'],\n            ]);\n\n            $wrapper->string('name', [\n                'label' => __('Feed Name', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Some podcast clients may display this title to describe the feed content.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]);\n\n            $wrapper->checkbox('append_name_to_podcast_title', [\n                'label' => __('Append Feed Name to Podcast title', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => sprintf(__('Structure of the feed title. Preview: %s', 'podlove-podcasting-plugin-for-wordpress'), $podcast->title.'<span id=\"feed_title_preview_append\"></span>'),\n                'default' => false,\n            ]);\n\n            $wrapper->string('slug', [\n                'label' => __('Slug', 'podlove-podcasting-plugin-for-wordpress').\\Podlove\\get_help_link('podlove_help_feed_slug'),\n                'description' => ($feed) ? sprintf(__('Feed identifier. URL Preview: %s', 'podlove-podcasting-plugin-for-wordpress'), '<span data-url=\"'.esc_attr($feed->get_subscribe_url()).'\" id=\"feed_subscribe_url_preview\">'.$feed->get_subscribe_url().'</span>') : '',\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]);\n\n            $wrapper->checkbox('discoverable', [\n                'label' => __('Discoverable?', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Embed a meta tag into the head of your site so browsers and feed readers will find the link to the feed.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => true,\n            ]);\n\n            $wrapper->checkbox('embed_content_encoded', [\n                'label' => __('Include HTML Content', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Include episode show notes in the feed.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => true,\n            ]);\n\n            $wrapper->checkbox('optimize_content_encoded_html', [\n                'label' => __('Optimize HTML Content', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Reduce feed HTML size by stripping non-essential attributes from episode show notes.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => false,\n            ]);\n\n            $podcast_settings = get_option('podlove_podcast');\n            if ($podcast_settings['limit_items'] < 0) {\n                $limit_default = 'No limit';\n            } else {\n                $limit_default = $podcast_settings['limit_items'];\n            }\n            $limit_options = [\n                '-2' => __('Use Podlove default ('.$limit_default.')', 'podlove-podcasting-plugin-for-wordpress'),\n                '-1' => __('No limit. Include all items.', 'podlove-podcasting-plugin-for-wordpress'),\n                '0' => __('Use WordPress Default', 'podlove-podcasting-plugin-for-wordpress').' ('.get_option('posts_per_rss').')',\n                1 => 1,\n            ];\n            for ($i = 1; $i * 5 <= 100; ++$i) {\n                $limit_options[$i * 5] = $i * 5;\n            }\n\n            $limit_options = apply_filters('podlove_feed_limit_options', $limit_options);\n\n            $wrapper->select('limit_items', [\n                'label' => __('Limit Items', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('If you have a lot of episodes, you might want to restrict the feed size.', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $limit_options,\n                'please_choose' => false,\n                'default' => '-2',\n            ]);\n\n            $wrapper->subheader(__('Directory Settings', 'podlove-podcasting-plugin-for-wordpress'));\n\n            $wrapper->checkbox('enable', [\n                'label' => __('Allow Submission to Directories', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Allow this feed to appear in podcast directories.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => true,\n            ]);\n\n            do_action('podlove_feeds_directories', $wrapper);\n\n            $wrapper->string('itunes_feed_id', [\n                'label' => __('iTunes Feed ID', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Is used to generate a link to the iTunes directory.', 'podlove-podcasting-plugin-for-wordpress').(($feed->itunes_feed_id) ? ' <a href=\"http://itunes.apple.com/podcast/id'.$feed->itunes_feed_id.'\" target=\"_blank\">'.__('Open in iTunes directory').'</a>' : ''),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->subheader(__('Reliable Feed Delivery', 'podlove-podcasting-plugin-for-wordpress'));\n\n            if (!FeedProxy::is_enabled()) {\n                $wrapper->select('redirect_http_status', [\n                    'label' => __('Redirect Method', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => __('\"Temporary Redirect\" is recommended.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'options' => [\n                        '0' => __('Don\\'t redirect', 'podlove-podcasting-plugin-for-wordpress'),\n                        '307' => __('Temporary Redirect (HTTP Status 307)', 'podlove-podcasting-plugin-for-wordpress'),\n                        '301' => __('Permanent Redirect (HTTP Status 301)', 'podlove-podcasting-plugin-for-wordpress'),\n                    ],\n                    'default' => 0,\n                    'please_choose' => false,\n                ]);\n            } else {\n                do_action('podlove_feed_settings_proxy', $wrapper, $feed);\n            }\n\n            $wrapper->string('redirect_url', [\n                'label' => __('Redirect Url', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('e.g. Feedburner URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n            ]);\n\n            do_action('podlove_feed_settings_bottom', $wrapper);\n        });\n    }\n\n    private function edit_template()\n    {\n        $feed = \\Podlove\\Model\\Feed::find_by_id($_REQUEST['feed']);\n        echo '<h3>'.sprintf(__('Edit Feed: %s', 'podlove-podcasting-plugin-for-wordpress'), esc_html($feed->name)).'</h3>';\n        $this->form_template($feed, 'save');\n    }\n}\n"
  },
  {
    "path": "lib/settings/file_type.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nclass FileType\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    private static $nonce = 'update_file_type';\n\n    public function __construct() {}\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<?php $this->view_template(); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    /**\n     * Helper method: redirect to a certain page.\n     *\n     * @param mixed      $action\n     * @param null|mixed $format_id\n     */\n    private function redirect($action, $format_id = null)\n    {\n        $page = 'admin.php?page='.htmlspecialchars($_REQUEST['page'] ?? '').'&podlove_tab='.htmlspecialchars($_REQUEST['podlove_tab'] ?? '');\n        $show = ($format_id) ? '&file_type='.$format_id : '';\n        $action = '&action='.$action;\n\n        wp_redirect(admin_url($page.$show.$action));\n        exit;\n    }\n\n    private function view_template()\n    {\n        ?>\n\t\t<p>\n\t\t\t<?php echo __('This is a list of all file types Podlove Publisher knows about.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n        $table = new \\Podlove\\File_Type_List_Table();\n        $table->prepare_items();\n        $table->display();\n    }\n}\n"
  },
  {
    "path": "lib/settings/help/analytics.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n    'podlove_analytics_intro' => [\n        'title' => __('Download Analytics', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n            .__('Podlove Publisher tracks <em>Download Intents</em>: the start of a download by a client. Those numbers do not represent if a download was completed or listened to.', 'podlove-podcasting-plugin-for-wordpress')\n            .'</p>'\n            .'<p>'\n            .sprintf(\n                __('For details on what is tracked and how, please visit: %s.', 'podlove-podcasting-plugin-for-wordpress'),\n                '<a href=\"http://docs.podlove.org/podlove-publisher/guides/download-analytics.html\" target=\"_blank\">'.__('Download Analytics Guide', 'podlove-podcasting-plugin-for-wordpress').'</a>'\n            )\n            .'</p>',\n    ],\n    'podlove_analytics_time' => [\n        'title' => __('Absolute &amp; Relative Time', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n            .__('In most statistics we use the episode release time as a starting point for calculations. Which means \"the first day\" is not a calendar-day but the first 24 hours after an episode was released. This enables meaningful comparisons between episodes.', 'podlove-podcasting-plugin-for-wordpress')\n            .'</p>'\n            .'<p>'\n            .__('However, when multiple episodes are plotted in the same chart with a time-axis, this is not possible (see downloads chart in the Analytics dashboard). Then absolute times are used.', 'podlove-podcasting-plugin-for-wordpress')\n            .'</p>'\n            .'<p>'\n            .__('Keep that in mind when comparing numbers between different statistics.', 'podlove-podcasting-plugin-for-wordpress')\n            .'</p>',\n    ],\n    'podlove_analytics_columns' => [\n        'title' => __('Column Names', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n            .'<ul>'\n            .'<li><strong>'.__('1d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First day (24 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('2d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 2 days (48 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('3d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 3 days (72 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('4d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 4 days (96 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('5d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 5 days (120 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('6d', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 6 days (144 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('1w', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 7 days (168 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('2w', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 2 weeks (336 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('3w', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 3 weeks (504 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('4w', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 4 weeks (672 hours) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('1q', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First quarter (13 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('2q', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 2 quarters (26 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('3q', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 3 quarters (39 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('1y', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First year (52 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('2y', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 2 years (104 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'<li><strong>'.__('3y', 'podlove-podcasting-plugin-for-wordpress').':</strong> '.__('First 3 years (156 weeks) after episode release', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n            .'</ul>'\n            .'</p>',\n    ],\n];\n"
  },
  {
    "path": "lib/settings/help/dashboard.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/episode_asset.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/feed.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n    'podlove_help_feed_slug' => [\n        'title' => __('Feed Slugs', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n                .__('Every feed URL is unique. To make it unique, you must assign each feed a unique <em>slug</em>.\n\t\t\t\t\tIt\\'s a good habit to use your asset:', 'podlove-podcasting-plugin-for-wordpress')\n                .'<ul>'\n                    .'<li>'.__('\"mp3\" slug for your mp3 asset', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n                    .'<li>'.__('\"m4a\" slug for your m4a asset', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n                    .'<li>'.__('etc.', 'podlove-podcasting-plugin-for-wordpress').'</li>'\n                .'</ul>'\n            .'</p>',\n    ],\n    'podlove_help_feed_asset' => [\n        'title' => __('Feed Assets', 'podlove-podcasting-plugin-for-wordpress'),\n        'content' => '<p>'\n                .__('Each feed contains exactly one asset. You should have one feed for each asset you want your users to be able to subscribe to.', 'podlove-podcasting-plugin-for-wordpress')\n            .'</p>',\n    ],\n];\n"
  },
  {
    "path": "lib/settings/help/modules.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/podcast.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/settings.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/support.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/help/templates.php",
    "content": "<?php\n\n// 'podlove_unique_tab_id' => [\n// \t'title'   => __('Tab Title', 'podlove-podcasting-plugin-for-wordpress'),\n// \t'content' =>\n// \t\t'<p>'\n// \t\t\t. __('Tab Content', 'podlove-podcasting-plugin-for-wordpress')\n// \t\t. '</p>'\n// ]\nreturn [\n];\n"
  },
  {
    "path": "lib/settings/modules.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nclass Modules\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        Modules::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Modules', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Modules', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_settings_modules_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_settings_section(\n            // $id\n            'podlove_settings_modules',\n            // $title\n            '',\n            // $callback\n            function () { // section head html\n            },\n            // $page\n            Modules::$pagehook\n        );\n\n        $grouped_modules = [];\n        $modules = \\Podlove\\Modules\\Base::get_all_module_names();\n        foreach ($modules as $module_name) {\n            $class = \\Podlove\\Modules\\Base::get_class_by_module_name($module_name);\n\n            if (!class_exists($class)) {\n                continue;\n            }\n\n            if ($class::is_core()) {\n                continue;\n            }\n\n            $module = $class::instance();\n            $module_options = $module->get_registered_options();\n\n            if ($group = $module->get_module_group()) {\n                add_settings_section(\n                    'podlove_setting_module_group_'.$group,\n                    ucwords($group),\n                    function () {},\n                    Modules::$pagehook\n                );\n            }\n\n            if ($module_options) {\n                register_setting(Modules::$pagehook, $module->get_module_options_name());\n            }\n\n            if ($class::is_visible()) {\n                add_settings_field(\n                    // $id\n                    'podlove_setting_module_'.$module_name,\n                    // $title\n                    '<input name=\"podlove_active_modules['.$module_name.']\" id=\"'.$module_name.'\" type=\"checkbox\" '.checked(\\Podlove\\Modules\\Base::is_active($module_name), true, false).'>'\n                    .sprintf(\n                        '<label for=\"'.$module_name.'\">%s</label><a name=\"'.$module_name.'\"></a>',\n                        $module->get_module_name()\n                    ),\n                    // $callback\n                    function () use ($module, $module_name, $module_options) {\n                        ?>\n                        <label for=\"<?php echo $module_name; ?>\">\n                            <?php echo $module->get_module_description(); ?>\n                        </label>\n                        <?php\n\n                        do_action('podlove_module_before_settings_'.$module_name);\n\n                        if ($module_options) {\n                            /**\n                             * ?><h4><?php echo __('Settings', 'podlove-podcasting-plugin-for-wordpress') ?></h4><?php.\n                             */\n\n                            // prepare settings object because form framework expects an object\n                            $settings_object = new \\stdClass();\n                            foreach ($module_options as $key => $value) {\n                                $settings_object->{$key} = $module->get_module_option($key);\n                            }\n\n                            \\Podlove\\Form\\build_for($settings_object, ['context' => $module->get_module_options_name(), 'submit_button' => false, 'form' => false], function ($form) use ($module_options) {\n                                $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n                                foreach ($module_options as $module_option_name => $args) {\n                                    call_user_func_array(\n                                        [$wrapper, $args['input_type']],\n                                        [\n                                            $module_option_name,\n                                            $args['args'],\n                                        ]\n                                    );\n                                }\n                            });\n                        }\n\n                        do_action('podlove_module_after_settings_'.$module_name);\n                    },\n                    // $page\n                    Modules::$pagehook,\n                    // $section\n                    $group ? 'podlove_setting_module_group_'.$group : 'podlove_settings_modules'\n                );\n            } else {\n                add_settings_field(\n                    // $id\n                    'podlove_setting_module_'.$module_name,\n                    // $title\n                    '',\n                    // $callback\n                    function () use ($module_name) {\n                        echo '<input name=\"podlove_active_modules['.$module_name.']\" id=\"'.$module_name.'\" type=\"hidden\" '.checked(\\Podlove\\Modules\\Base::is_active($module_name), true, false).'>'; ?>\n                        <script>\n                        (function($){\n                            $(document).ready(function() {\n                                $(\"input#<?php echo $module_name; ?>\").closest(\"tr\").hide();\n                            });\n                        }(jQuery));\n                        </script>\n                        <?php\n                    },\n                    // $page\n                    Modules::$pagehook,\n                    // $section\n                    $group ? 'podlove_setting_module_group_'.$group : 'podlove_settings_modules'\n                );\n            }\n        }\n\n        register_setting(Modules::$pagehook, 'podlove_active_modules');\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Podlove Publisher Modules', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t\t<?php settings_fields(Modules::$pagehook); ?>\n\t\t\t\t<?php do_settings_sections(Modules::$pagehook); ?>\n\n\t\t\t\t<?php submit_button(__('Save Changes', 'podlove-podcasting-plugin-for-wordpress'), 'button-primary', 'submit', true); ?>\n\t\t\t</form>\n\t\t</div>\n\n<style>\nform > .form-table > tbody > tr {\n\tbackground: white;\n\tdisplay: block;\n\tmargin-bottom: 2em;\n\tpadding: 1.25em;\n\tbox-shadow: 1px 1px 2px rgba(0,0,0, 0.1), 0px 0px 4px rgba(0,0,0, 0.05);\n    max-width: 600px;\n}\n\nform > .form-table > tbody > tr > th,\nform > .form-table > tbody > tr > td {\n\tdisplay: block;\n\tpadding: 0;\n\tmargin: 0;\n    width: 100%;\n}\n\nform > .form-table > tbody > tr > th {\n    margin-bottom: 1.25em;\n}\n\nform > .form-table > tbody > tr > th label {\n    margin-left: 0.5em;\n    margin-top: -4px;\n}\n\nform > .form-table th label {\n\tfont-size: 16px;\n\tdisplay: inline-block;\n}\n\nform > .form-table h4 {\n\tfont-size: 16px;\n}\n\n.form-table .form-table th {\n    width: 150px;\n}\n\nform > h2 {\n    margin-top: 3em;\n    color: #32373c;\n    font-size: 2em;\n    font-weight: 600;\n    letter-spacing: 0.025em;\n}\n\n@media (min-width:1280px) {\n    form > .form-table > tbody {\n        display: grid;\n        grid-gap: 1.5rem;\n        gap: 1.5rem;\n        grid-template-columns: repeat(2,minmax(0,1fr));\n        max-width: 1350px;\n    }\n}\n\n</style>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab/description.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast\\Tab;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass Description extends Tab\n{\n    private static $nonce = 'update_podcast_settings_description';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_GET['page']) || $_GET['page'] !== 'podlove_settings_podcast_handle') {\n            return;\n        }\n\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = ['title', 'subtitle', 'summary', 'language', 'cover_image', 'itunes_type', 'mnemonic'];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = wp_kses(stripslashes($_POST['podlove_podcast'][$key]), wp_kses_allowed_html('post'));\n        }\n        update_option('podlove_podcast', $settings);\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php _e('These are the three most important fields describing your podcast.\n\t\t\t\t\t<strong>Title</strong> is the title of the podcast that is the primary field to be used to represent the podcast in directories, lists and other uses.\n\t\t\t\t\tThe <strong>subtitle</strong> is an extension to the title. The subtitle is meant to clarify what the podcast is about. While a title can be anything, a subtitle should be more descriptive in what the content actually wants to convey and what the most important information is, you want everybody want to know about the offering.\n\t\t\t\t\tA <strong>summary</strong> is a much more precise and elaborate description of the podcast\\'s content. While title and subtitle are rather concise, a summary is meant to consist of one or more sentences that form a paragraph or more.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n\n        \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $wrapper->string('title', [\n                'label' => __('Title', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]);\n\n            $wrapper->string('subtitle', [\n                'label' => __('Subtitle', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Extension to the title. Clarify what the podcast is about.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->text('summary', [\n                'label' => __('Summary', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Elaborate description of the podcast\\'s content.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['rows' => 3, 'cols' => 40, 'class' => 'autogrow podlove-check-input'],\n            ]);\n\n            $wrapper->upload('cover_image', [\n                'label' => __('Image URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Apple/iTunes recommends 3000 x 3000 pixel JPG or PNG.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n                'media_button_text' => __('Use for Podcast Cover Art', 'podlove-podcasting-plugin-for-wordpress'),\n            ]);\n\n            $wrapper->string('mnemonic', [\n                'label' => __('Mnemonic', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Abbreviation for your podcast. Usually 2–4 capital letters, used to reference episodes. For example, the podcast \"The Lunatic Fringe\" might have the mnemonic TLF and its fifth episode can be referred to via TLF005.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text required podlove-check-input'],\n            ]);\n\n            $wrapper->select('language', [\n                'label' => __('Language', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => '',\n                'default' => get_bloginfo('language'),\n                'options' => \\Podlove\\Locale\\locales(),\n            ]);\n\n            $wrapper->select('itunes_type', [\n                'label' => __('Type', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Should your podcast be presented last-to-first or first-to-last in podcast clients? Clients may or may not support this feature.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => 'episodic',\n                'please_choose' => false,\n                'options' => [\n                    'episodic' => __('Episodic: Stand-alone episodes that should be presented last-to-first.', 'podlove-podcasting-plugin-for-wordpress'),\n                    'serial' => __('Serial: Episodes that should be presented first-to-last. Great for narratives, storytelling, thematic, and multiple seasons.', 'podlove-podcasting-plugin-for-wordpress'),\n                ],\n            ]);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab/directory.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast\\Tab;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass Directory extends Tab\n{\n    private static $nonce = 'update_podcast_settings_directory';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = [\n            'author_name',\n            'publisher_name',\n            'publisher_url',\n            'owner_name',\n            'owner_email',\n            'category_1',\n            'category_2',\n            'category_3',\n            'explicit',\n            'complete',\n            'funding_url',\n            'funding_label',\n            'copyright',\n        ];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            if (isset($_POST['podlove_podcast'][$key])) {\n                $settings[$key] = stripslashes($_POST['podlove_podcast'][$key]);\n            } else {\n                $settings[$key] = null;\n            }\n        }\n\n        update_option('podlove_podcast', $settings);\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php _e('You may provide additional information about your podcast that may or may not be used by podcast directories like iTunes.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n\n        \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n            $podcast = $form->object;\n\n            $wrapper->string('author_name', [\n                'label' => __('Author Name', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Publicly displayed in Podcast directories.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->string('publisher_name', [\n                'label' => __('Publisher Name', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->string('publisher_url', [\n                'label' => __('Publisher URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n            ]);\n\n            $wrapper->string('owner_name', [\n                'label' => __('Owner Name', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Used by iTunes and other Podcast directories to contact you.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->string('owner_email', [\n                'label' => __('Owner Email', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Used by iTunes and other Podcast directories to contact you.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'email'],\n            ]);\n\n            $wrapper->select('category_1', [\n                'label' => __('iTunes Categories', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => '',\n                'type' => 'select',\n                'options' => \\Podlove\\Itunes\\categories(),\n            ]);\n\n            $wrapper->select('category_2', [\n                'label' => __('iTunes Categories', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Optional. May be ignored by directories.'),\n                'type' => 'select',\n                'options' => \\Podlove\\Itunes\\categories(),\n            ]);\n\n            $wrapper->select('category_3', [\n                'label' => __('iTunes Categories', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Optional. May be ignored by directories.'),\n                'type' => 'select',\n                'options' => \\Podlove\\Itunes\\categories(),\n            ]);\n\n            $wrapper->select('explicit', [\n                'label' => __('Explicit Content?', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('True: If you specify true, indicating the presence of explicit content, directories may display an Explicit parental advisory graphic for your podcast. False: If you specify false, indicating that your podcast does not contain explicit language or adult content, directories may display a Clean parental advisory graphic for your podcast.', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => [0 => 'false', 1 => 'true'],\n            ]);\n\n            $wrapper->checkbox('complete', [\n                'label' => __('Podcast complete?', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Shows that this Podcast is finished and no further episodes will be added.', 'podlove-podcasting-plugin-for-wordpress'),\n                'default' => false,\n            ]);\n\n            $wrapper->string('funding_url', [\n                'label' => __('Funding URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Can be used by podcatchers show funding/donation links for the podcast.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n            ]);\n\n            $wrapper->string('funding_label', [\n                'label' => __('Funding Label', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Label for funding/donation URL.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text'],\n            ]);\n\n            $wrapper->string('copyright', [\n                'label' => __('Copyright', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Copyright notice for content in the channel. If you leave this blank, a default copyright notice will appear in the feed because it is required by the Apple Podcasts Connect.', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text', 'placeholder' => \\esc_attr($podcast->default_copyright_claim())],\n            ]);\n        });\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab/license.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast\\Tab;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass License extends Tab\n{\n    private static $nonce = 'update_podcast_settings_license';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = [\n            'license_name',\n            'license_url',\n        ];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_podcast'][$key];\n        }\n        update_option('podlove_podcast', $settings);\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ];\n\n        \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $wrapper->string('license_name', [\n                'label' => __('License Name', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input'],\n            ]);\n\n            $wrapper->string('license_url', [\n                'label' => __('License URL', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => ['class' => 'regular-text podlove-check-input', 'data-podlove-input-type' => 'url'],\n                'description' => __('Example: http://creativecommons.org/licenses/by/3.0/', 'podlove-podcasting-plugin-for-wordpress'),\n            ]); ?>\n\n\t\t\t\t<tr class=\"row_podlove_cc_license_selector_toggle\">\n\t\t\t\t\t<th></th>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<span id=\"podlove_cc_license_selector_toggle\">\n\t\t\t\t\t\t\t<span class=\"_podlove_episode_list_triangle\">&#9658;</span>\n\t\t\t\t\t\t\t<span class=\"_podlove_episode_list_triangle_expanded\">&#9660;</span>\n\t\t\t\t\t\t\t<?php _e('License Selector', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr class=\"row_podlove_cc_license_selector\">\n\t\t\t\t\t<th></th>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label for=\"license_cc_version\" class=\"podlove_cc_license_selector_label\"><?php _e('Version', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t\t\t\t<select id=\"license_cc_version\">\n\t\t\t\t\t\t\t\t<option value=\"cc0\"><?php _e('Public Domain', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"pdmark\"><?php _e('Public Domain Mark', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"cc3\"><?php _e('Creative Commons 3.0 and earlier', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"cc4\"><?php _e('Creative Commons 4.0', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"podlove-hide\">\n\t\t\t\t\t\t\t<label for=\"license_cc_allow_modifications\" class=\"podlove_cc_license_selector_label\"><?php _e('Allow modifications of your work?', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t\t\t\t<select id=\"license_cc_allow_modifications\">\n\t\t\t\t\t\t\t\t<option value=\"yes\"><?php _e('Yes', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"yesbutshare\"><?php _e('Yes, as long as others share alike', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"no\"><?php _e('No', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"podlove-hide\">\n\t\t\t\t\t\t\t<label for=\"license_cc_allow_commercial_use\" class=\"podlove_cc_license_selector_label\"><?php _e('Allow commercial uses of your work?', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t\t\t\t<select id=\"license_cc_allow_commercial_use\">\n\t\t\t\t\t\t\t\t<option value=\"yes\"><?php _e('Yes', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t\t<option value=\"no\"><?php _e('No', 'podlove-podcasting-plugin-for-wordpress'); ?></option>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"podlove-hide\">\n\t\t\t\t\t\t\t<label for=\"license_cc_license_jurisdiction\" class=\"podlove_cc_license_selector_label\"><?php _e('License Jurisdiction', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t\t\t\t<select id=\"license_cc_license_jurisdiction\">\n\t\t\t\t\t\t\t\t<?php\n                                    foreach (\\Podlove\\License\\locales_cc() as $locale_key => $locale_description) {\n                                        echo \"<option value='\".$locale_key.\"' \".($locale_key == 'international' ? \"selected='selected'\" : '').'>'.$locale_description.\"</option>\\n\";\n                                    } ?>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t\t<tr class=\"row_podlove_podcast_license_preview\">\n\t\t\t\t\t<th scope=\"row\" valign=\"top\">\n\t\t\t\t\t\t\t<label for=\"podlove_podcast_subtitle\"><?php _e('License Preview', 'podlove-podcasting-plugin-for-wordpress'); ?></label>\n\t\t\t\t\t</th>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<p class=\"podlove_podcast_license_image\"></p>\n\t\t\t\t\t\t<div class=\"podlove_license\">\n\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t<?php _e('This work is licensed under the', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t\t\t\t<a class=\"podlove-license-link\" rel=\"license\" href=\"\"></a>.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t<?php\n        }); ?>\n\t\t<script type=\"text/javascript\">\n\t\tPODLOVE.License({\n\t\t\tplugin_url: \"<?php echo \\Podlove\\PLUGIN_URL; ?>\",\n\n\t\t\ttypes: JSON.parse('<?php echo wp_json_encode(\\Podlove\\License\\locales_cc()); ?>'),\n\t\t\tlocales: JSON.parse('<?php echo wp_json_encode(\\Podlove\\License\\locales_cc()); ?>'),\n\t\t\tversions: JSON.parse('<?php echo wp_json_encode(\\Podlove\\License\\version_per_country_cc()); ?>'),\n\t\t\tlicense: JSON.parse('<?php echo wp_json_encode(\\Podlove\\Model\\License::get_license_from_url($podcast->license_url)); ?>'),\n\n\t\t\tlicense_name_field_id: '#podlove_podcast_license_name',\n\t\t\tlicense_url_field_id: '#podlove_podcast_license_url'\n\t\t});\n\n\t\t</script>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab/media.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast\\Tab;\n\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass Media extends Tab\n{\n    private static $nonce = 'update_podcast_settings_media';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_podcast']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = ['media_file_base_uri'];\n\n        $settings = get_option('podlove_podcast');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_podcast'][$key];\n        }\n        update_option('podlove_podcast', $settings);\n        header('Location: '.$this->get_url());\n    }\n\n    public function register_page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n\n        $form_attributes = [\n            'context' => 'podlove_podcast',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ]; ?>\n\t\t<p>\n\t\t\t<?php _e('The Podlove Publisher expects all your media files to be in the same <strong>Upload Location</strong>.\n\t\t\t\t\tIt should be a publicly readable directory containing all media files.\n\t\t\t\t\tYou should not create a separate directory for each episode.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t</p>\n\t\t<?php\n\n        \\Podlove\\Form\\build_for($podcast, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $wrapper->string('media_file_base_uri', apply_filters('podlove_media_file_base_uri_form', [\n                'label' => __('Upload Location', 'podlove-podcasting-plugin-for-wordpress'),\n                'description' => __('Example: http://cdn.example.com/pod/', 'podlove-podcasting-plugin-for-wordpress'),\n                'html' => [\n                    'class' => 'large-text required podlove-check-input',\n                    'data-podlove-input-type' => 'url'\n                ],\n            ]));\n        });\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab/player.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast\\Tab;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass Player extends Tab\n{\n    private static $nonce = 'update_podcast_settings_player';\n\n    public function init()\n    {\n        add_action($this->page_hook, [$this, 'register_page']);\n        add_action('admin_init', [$this, 'process_form']);\n    }\n\n    public function process_form()\n    {\n        if (!isset($_POST['podlove_webplayer_settings']) || !$this->is_active()) {\n            return;\n        }\n\n        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], self::$nonce)) {\n            return;\n        }\n\n        $formKeys = array_keys(\\Podlove\\get_webplayer_defaults());\n\n        $settings = get_option('podlove_webplayer_settings');\n        foreach ($formKeys as $key) {\n            $settings[$key] = $_POST['podlove_webplayer_settings'][$key] ?? null;\n        }\n\n        update_option('podlove_webplayer_settings', $settings);\n        \\Podlove\\Cache\\TemplateCache::get_instance()->setup_purge();\n\n        header('Location: '.$this->get_url());\n    }\n\n    public static function get_form_data()\n    {\n        $form_data = [\n            [\n                'type' => 'select',\n                'key' => 'version',\n                'options' => [\n                    'label' => __('Web Player', 'podlove-podcasting-plugin-for-wordpress'),\n                    'description' => '',\n                    'options' => [\n                        'player_v4' => __('Podlove Web Player 4 (deprecated)', 'podlove-podcasting-plugin-for-wordpress'),\n                        'player_v5' => __('Podlove Web Player 5', 'podlove-podcasting-plugin-for-wordpress'),\n                        'podigee' => __('Podigee Podcast Player', 'podlove-podcasting-plugin-for-wordpress'),\n                    ],\n                ],\n                'position' => 1000,\n            ],\n        ];\n\n        // allow modules to add / change the form\n        $form_data = apply_filters('podlove_player_form_data', $form_data);\n\n        // sort entities by position\n        usort($form_data, [__CLASS__, 'compare_by_position']);\n\n        return $form_data;\n    }\n\n    public static function compare_by_position($a, $b)\n    {\n        $pos_a = isset($a['position']) ? (int) $a['position'] : 0;\n        $pos_b = isset($b['position']) ? (int) $b['position'] : 0;\n\n        if ($a == $b || $pos_a == $pos_b) {\n            return 0;\n        }\n\n        return ($pos_a < $pos_b) ? 1 : -1;\n    }\n\n    public function register_page()\n    {\n        $form_attributes = [\n            'context' => 'podlove_webplayer_settings',\n            'action' => $this->get_url(),\n            'nonce' => self::$nonce\n        ];\n\n        $form_data = self::get_form_data();\n\n        \\Podlove\\Form\\build_for((object) \\Podlove\\get_webplayer_settings(), $form_attributes, function ($form) use ($form_data) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            foreach ($form_data as $entry) {\n                $wrapper->{$entry['type']}($entry['key'], $entry['options']);\n            }\n        });\n\n        $this->preview_section();\n    }\n\n    public function preview_section()\n    {\n        $episode = Episode::latest();\n        if ($episode) {\n            $this->preview_player($episode);\n        } else {\n            $this->preview_player(new Episode());\n        }\n    }\n\n    public function preview_player($episode)\n    {\n        $printer = \\Podlove\\Modules\\PodloveWebPlayer\\Podlove_Web_Player::get_player_printer($episode);\n        if ($printer && method_exists($printer, 'render')) {\n            echo '<h3>Preview</h3>';\n            echo $printer->render('preview');\n        }\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast/tab.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Podcast;\n\nuse Podlove\\Settings\\Settings;\n\n/**\n * Represents one Expert Settings Tab.\n */\nclass Tab\n{\n    protected $page_hook = 'podlove_podcast_settings_page';\n\n    /**\n     * Tab title.\n     *\n     * @var string\n     */\n    private $title;\n\n    /**\n     * Tab slug used in URLs.\n     *\n     * @var string\n     */\n    private $slug;\n\n    /**\n     * If this is true, use it if no tab is selected.\n     *\n     * @var bool\n     */\n    private $is_default;\n\n    public function __construct($slug, $title, $is_default = false)\n    {\n        $this->slug = strtolower(\\Podlove\\slugify($slug));\n        $this->set_title($title);\n        $this->is_default = $is_default;\n    }\n\n    public function is_active()\n    {\n        $is_current_tab = isset($_REQUEST['podlove_tab']) && $this->get_slug() == $_REQUEST['podlove_tab'];\n\n        return $is_current_tab || !isset($_REQUEST['podlove_tab']) && $this->is_default;\n    }\n\n    public function get_title()\n    {\n        return $this->title;\n    }\n\n    public function set_title($title)\n    {\n        $this->title = $title;\n    }\n\n    public function get_slug()\n    {\n        return $this->slug;\n    }\n\n    public function get_url()\n    {\n        return sprintf('?page=%s&podlove_tab=%s', htmlspecialchars($_REQUEST['page'] ?? ''), $this->get_slug());\n    }\n\n    public function page()\n    {\n        do_action($this->page_hook);\n    }\n\n    public function init()\n    {\n        throw Exception('You need to subclass Tab and implement Tab::init');\n    }\n}\n"
  },
  {
    "path": "lib/settings/podcast.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Settings\\Expert\\Tabs;\nuse Podlove\\Settings\\Podcast\\Tab;\n\nclass Podcast\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n    private $tabs;\n\n    public function __construct($handle)\n    {\n        Podcast::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Podcast Settings', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Podcast Settings', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_settings_podcast_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_settings_section(\n            // $id\n            'podlove_podcast_general',\n            // $title\n            __('Podcast Settings', 'podlove-podcasting-plugin-for-wordpress'),\n            // $callback\n            function () { // section head html\n            },\n            // $page\n            Podcast::$pagehook\n        );\n\n        register_setting(Podcast::$pagehook, 'podlove_podcast', function ($podcast) {\n            if ($podcast['media_file_base_uri']) {\n                $podcast['media_file_base_uri'] = trailingslashit($podcast['media_file_base_uri']);\n            }\n\n            return $podcast;\n        });\n\n        $tabs = new Tabs(__('Podcast Settings', 'podlove-podcasting-plugin-for-wordpress'));\n        $tabs->addTab(new Tab\\Description('description', __('Description', 'podlove-podcasting-plugin-for-wordpress'), true));\n        $tabs->addTab(new Tab\\Media('media', __('Media', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\Player('player', __('Player', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\License('license', __('License', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\Directory('directory', __('Directory', 'podlove-podcasting-plugin-for-wordpress')));\n        $this->tabs = apply_filters('podlove_podcast_settings_tabs', $tabs);\n        $this->tabs->initCurrentTab();\n    }\n\n    public function page()\n    {\n        $podcast = \\Podlove\\Model\\Podcast::get();\n        $guid = trim((string) $podcast->guid);\n\n        ?>\n\t\t<div class=\"wrap\">\n\n\t\t\t<?php\n            echo $this->tabs->getTabsHTML();\n        echo $this->tabs->getCurrentTabPage(); ?>\n\n\t\t\t<?php if ($guid !== '') { ?>\n\t\t\t\t<p class=\"description\" style=\"margin-top: 1rem;\">\n\t\t\t\t\t<?php _e('Podcast GUID:', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t\t\t<code><?php echo esc_html($guid); ?></code>\n\t\t\t\t</p>\n\t\t\t<?php } ?>\n\t\t\t\n\t\t</div>\t\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/settings.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Settings\\Expert\\Tab;\nuse Podlove\\Settings\\Expert\\Tabs;\n\n/**\n * Expert Settings.\n */\nclass Settings\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n    private $tabs;\n\n    public function __construct($handle)\n    {\n        Settings::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Expert Settings', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Expert Settings', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_settings_settings_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        if (filter_input(INPUT_GET, 'page') !== 'podlove_settings_settings_handle' && !\\Podlove\\is_options_save_page()) {\n            return;\n        }\n\n        $tabs = new Tabs(__('Expert Settings', 'podlove-podcasting-plugin-for-wordpress'));\n        $tabs->addTab(new Tab\\Website(__('Website', 'podlove-podcasting-plugin-for-wordpress'), true));\n        $tabs->addTab(new Tab\\Metadata(__('Metadata', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\Redirects(__('Redirects', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\WebPlayer(__('Web Player', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\FileTypes(__('File Types', 'podlove-podcasting-plugin-for-wordpress')));\n        $tabs->addTab(new Tab\\Tracking(__('Tracking', 'podlove-podcasting-plugin-for-wordpress')));\n        $this->tabs = $tabs;\n        $this->tabs->initCurrentTab();\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<?php\n            echo $this->tabs->getTabsHTML();\n        echo $this->tabs->getCurrentTabPage(); ?>\n\t\t</div>\t\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/support.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nclass Support\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        Support::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Support', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Support', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_Support_settings_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Support', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n    \t\t<h3><?php echo __('Bug Reports, Feature Requests & Help', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\n\t\t\t<p>\n\t\t\t\t<ul>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('Please report bugs at %sGitHub Issues%s.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    '<a href=\"https://github.com/podlove/podlove-publisher/issues\" target=\"_blank\">',\n\t\t\t\t\t\t    '</a>'\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('%sPodlove Community%s is the best place to find answers, ask the community for help and discuss features.', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    '<a target=\"_blank\" href=\"//community.podlove.org\">',\n\t\t\t\t\t\t    '</a>'\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<?php echo sprintf(\n\t\t\t\t\t\t    __('For quick remarks and feedback, you can reach us at %sMastodon (fosstodon.org/@podlove)%s', 'podlove-podcasting-plugin-for-wordpress'),\n\t\t\t\t\t\t    '<a target=\"_blank\" href=\"https://fosstodon.org/@podlove\">',\n\t\t\t\t\t\t    '</a>'\n\t\t\t\t\t\t); ?>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</p>\n\n\t\t\t<p>\n\t\t\t\t<?php echo __('When reporting a bug, please append the following system report to help us trace the root cause:', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\n\t\t\t<p>\n\t\t\t\t<?php\n                $r = new \\Podlove\\SystemReport();\n        $report = $r->render(); ?>\n\t\t\t\t<textarea class=\"podlove_system_report\" readonly cols=\"100\" rows=\"<?php echo substr_count($report, \"\\n\") + 1; ?>\"><?php echo $report; ?></textarea>\n\t\t\t</p>\n\n\t\t</div>\t\n\t\t<?php\n\n        do_action('podlove_support_page_footer');\n    }\n}\n"
  },
  {
    "path": "lib/settings/templates.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Cache\\TemplateCache;\nuse Podlove\\Model;\n\nclass Templates\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        self::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Templates', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Templates', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_templates_settings_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_action('admin_init', [$this, 'scripts_and_styles']);\n\n        register_setting(Templates::$pagehook, 'podlove_template_assignment', function ($args) {\n            // when changing the assignment, clear caches\n            TemplateCache::get_instance()->purge();\n\n            return $args;\n        });\n    }\n\n    public function scripts_and_styles()\n    {\n        if (!isset($_REQUEST['page'])) {\n            return;\n        }\n\n        if ($_REQUEST['page'] != 'podlove_templates_settings_handle') {\n            return;\n        }\n\n        wp_register_script('podlove-ace-js', \\Podlove\\PLUGIN_URL.'/js/admin/ace/ace.js');\n\n        wp_register_script('podlove-template-js', \\Podlove\\PLUGIN_URL.'/js/admin/template.js', ['jquery', 'podlove-ace-js']);\n\n        wp_localize_script(\n            'podlove-template-js',\n            'podlove_admin_network_global',\n            [\n                'is_network_admin' => is_network_admin()\n            ]\n        );\n\n        wp_enqueue_script('podlove-template-js');\n    }\n\n    public function page()\n    {\n        ?>\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Templates', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\t\t\t<?php\n$this->view_template(); ?>\n\t\t</div>\n\t\t<?php\n    }\n\n    private function view_template()\n    {\n        echo sprintf(\n            __('Episode Templates are an easy way to keep the same structure in all your episodes.\n\t\t\t\tYou can use %sShortcodes%s as well as %sPublisher Template Tags%s to customize your episodes.<br>\n\t\t\t\tPlease read the %sTemplating Guide%s to get started.\n\t\t\t\t', 'podlove-podcasting-plugin-for-wordpress'),\n            '<a href=\"https://docs.podlove.org/podlove-publisher/reference/shortcodes\" target=\"_blank\">',\n            '</a>',\n            '<a href=\"https://docs.podlove.org/podlove-publisher/reference/templates/template-tags/podcast\" target=\"_blank\">',\n            '</a>',\n            '<a href=\"https://docs.podlove.org/podlove-publisher/guides/templates\" target=\"_blank\">',\n            '</a>'\n        ); ?>\n\t\t<div id=\"template-editor\">\n\t\t\t<div class=\"navigation\">\n\t\t\t\t<ul>\n\t\t\t\t\t<?php foreach (Model\\Template::all() as $template) { ?>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<a href=\"#\" data-id=\"<?php echo $template->id; ?>\">\n\t\t\t\t\t\t\t\t<span class=\"filename\"><?php echo $template->title; ?></span>&nbsp;\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t</ul>\n\t\t\t\t<div class=\"add\">\n\t\t\t\t\t<a href=\"#\">+ <?php _e('add new template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"editor\">\n\t\t\t\t<div class=\"toolbar\">\n\t\t\t\t\t<div class=\"title\">\n\t\t\t\t\t\t<input type=\"text\">\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"clear\"></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"editor-wrapper\">\n\t\t\t\t\t<div class=\"main\" id=\"ace-editor\"></div>\n\t\t\t\t\t<div id=\"fullscreen\" class=\"fullscreen-on fullscreen-button\"></div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"clear\"></div>\n\t\t\t<footer>\n\t\t\t  <div class=\"actions\">\n\t\t\t\t\t<a href=\"#\" class=\"save button button-primary\"><?php _e('Save Template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t  \t<a href=\"#\" class=\"delete\"><?php _e('Delete Template', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t\t\t  </div>\n\t\t\t</footer>\n\t\t\t<div class=\"clear\"></div>\n\t\t</div>\n\n\t\t<div class=\"podlove-template-shortcode\" style=\"margin-top: 8px\">\n\t\t  <div>\n\t  \t\t<strong><?php _e('Embed with Shortcode', 'podlove-podcasting-plugin-for-wordpress'); ?></strong>\n\t\t\t</div>\n\t\t  <div style=\"margin-top: 4px; display: flex\">\n\t\t\t\t<input id=\"podlove_template_shortcode_preview\" class=\"regular-text code\" value=\"\" style=\"margin-right: 8px\">\n\n\t\t\t\t<button class=\"button clipboard-btn\" data-clipboard-target=\"#podlove_template_shortcode_preview\">\n\t\t\t\t\tCopy to Clipboard\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"podlove-form-card\" style=\"margin-top: 40px\">\n\t\t<h3><?php _e('Insert templates to content automatically', 'podlove-podcasting-plugin-for-wordpress'); ?></h3>\n\n\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t<?php settings_fields(Templates::$pagehook);\n        $template_assignment = Model\\TemplateAssignment::get_instance();\n\n        $form_attributes = [\n            'context' => 'podlove_template_assignment',\n            'form' => false,\n        ];\n\n        \\Podlove\\Form\\build_for($template_assignment, $form_attributes, function ($form) {\n            $wrapper = new \\Podlove\\Form\\Input\\TableWrapper($form);\n\n            $templates = [0 => __('Don\\'t insert automatically', 'podlove-podcasting-plugin-for-wordpress')];\n            foreach (Model\\Template::all_globally() as $template) {\n                $templates[$template->title] = $template->title;\n            }\n\n            $wrapper->select('top', [\n                'label' => __('Insert at top', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $templates,\n                'please_choose' => false,\n            ]);\n\n            $wrapper->select('bottom', [\n                'label' => __('Insert at bottom', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $templates,\n                'please_choose' => false,\n            ]);\n\n            $wrapper->select('head', [\n                'label' => __('Insert in document head', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $templates,\n                'please_choose' => false,\n            ]);\n\n            $wrapper->select('header', [\n                'label' => __('Insert before header', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $templates,\n                'please_choose' => false,\n            ]);\n\n            $wrapper->select('footer', [\n                'label' => __('Insert after footer', 'podlove-podcasting-plugin-for-wordpress'),\n                'options' => $templates,\n                'please_choose' => false,\n            ]);\n        }); ?>\n\t\t</form>\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/settings/tools/user_agent_refresh.php",
    "content": "<?php\n\nnamespace Podlove\\Settings\\Tools;\n\nuse Podlove\\Model\\UserAgent;\n\n// TODO: replace with job system\nclass UserAgentRefresh\n{\n    public function __construct()\n    {\n        add_action('wp_ajax_podlove-useragentrefresh', [$this, 'refresh']);\n    }\n\n    public function refresh()\n    {\n        $offset = filter_input(INPUT_GET, 'offset', FILTER_SANITIZE_NUMBER_INT);\n\n        if (!$offset) {\n            $offset = 0;\n        }\n\n        // podlove_refresh_user_agents($offset);\n\n        echo wp_json_encode([\n            'offset' => $offset + 500,\n            'total' => UserAgent::count(),\n        ]);\n        exit;\n    }\n}\n"
  },
  {
    "path": "lib/settings/tools.php",
    "content": "<?php\n\nnamespace Podlove\\Settings;\n\nuse Podlove\\Model\\Job;\n\nclass Tools\n{\n    use \\Podlove\\HasPageDocumentationTrait;\n\n    public static $pagehook;\n\n    public function __construct($handle)\n    {\n        Tools::$pagehook = add_submenu_page(\n            // $parent_slug\n            $handle,\n            // $page_title\n            __('Tools', 'podlove-podcasting-plugin-for-wordpress'),\n            // $menu_title\n            __('Tools', 'podlove-podcasting-plugin-for-wordpress'),\n            // $capability\n            'administrator',\n            // $menu_slug\n            'podlove_tools_settings_handle',\n            // $function\n            [$this, 'page']\n        );\n\n        $this->init_page_documentation(self::$pagehook);\n\n        add_action('admin_init', [$this, 'process_actions']);\n\n        add_action('admin_print_styles', function () {\n            wp_enqueue_script('podlove_admin_jobs');\n        }, 20);\n\n        \\Podlove\\add_tools_section('general-maintenance', __('General Maintenance', 'podlove-podcasting-plugin-for-wordpress'));\n        \\Podlove\\add_tools_section('tracking-analytics', __('Tracking & Analytics', 'podlove-podcasting-plugin-for-wordpress'));\n\n        // Fields for section \"General Maintenance\"\n        \\Podlove\\add_tools_field('gm-clear-caches', __('Clear Caches', 'podlove-podcasting-plugin-for-wordpress'), function () {\n            ?>\n\t\t\t<a href=\"<?php echo esc_url(admin_url('admin.php?page='.$_REQUEST['page'].'&action=clear_caches&nonce='.wp_create_nonce('podlove_tools'))); ?>\" class=\"button\">\n\t\t\t\t<?php echo __('Clear Caches', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</a>\n\t\t\t<p class=\"description\">\n\t\t\t\t<?php echo __('Sometimes an issue is already fixed but you still see the faulty output. Clearing the cache avoids this. However, if you use a third party caching plugin, you should clear that cache, too.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<?php\n        }, 'general-maintenance');\n\n        \\Podlove\\add_tools_field('gm-repair', __('Repair', 'podlove-podcasting-plugin-for-wordpress'), function () {\n            \\Podlove\\Repair::page();\n        }, 'general-maintenance');\n\n        /**\n         * Fields for section \"Tracking & Analytics\".\n         */\n        $job_class = 'Podlove\\Jobs\\UserAgentRefreshJob';\n        \\Podlove\\add_tools_field('ta-recals-agents', $job_class::title(), function () use ($job_class) {\n            $recent_job = Job::find_one_recent_job($job_class);\n            $recent_job_id = $recent_job ? $recent_job->id : ''; ?>\n\n\t\t\t<div\n\t\t\t\tclass=\"podlove-job\"\n\t\t\t\tdata-job=\"Podlove-Jobs-UserAgentRefreshJob\"\n\t\t\t\tdata-button-text=\"<?php echo sprintf(__('Start %s', 'podlove-podcasting-plugin-for-wordpress'), $job_class::title()); ?>\"\n\t\t\t\tdata-recent-job-id=\"<?php echo $recent_job_id; ?>\"\n\t\t\t\t>\n\n\t\t\t</div>\n\n\t\t\t<div class=\"clear\"></div>\n\n\t\t\t<p class=\"description\">\n\t\t\t\t<?php echo __('Runs automatically on plugin updates. Update user agent metadata based on <code>device-detector</code> library.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<?php\n        }, 'tracking-analytics');\n\n        $job_class = 'Podlove\\Jobs\\DownloadIntentCleanupJob';\n        \\Podlove\\add_tools_field('ta-recalc-analytics', $job_class::title(), function () use ($job_class) {\n            $recent_job = Job::find_one_recent_job($job_class);\n            $recent_job_id = $recent_job ? $recent_job->id : ''; ?>\n\n\t\t\t<div\n\t\t\t\tclass=\"podlove-job\"\n\t\t\t\tdata-job=\"Podlove-Jobs-DownloadIntentCleanupJob\"\n\t\t\t\tdata-button-text=\"<?php echo sprintf(__('Start %s', 'podlove-podcasting-plugin-for-wordpress'), $job_class::title()); ?>\"\n\t\t\t\tdata-recent-job-id=\"<?php echo $recent_job_id; ?>\"\n\t\t\t\t>\n\n\t\t\t</div>\n\n\t\t\t<div class=\"clear\"></div>\n\n\t\t\t<p class=\"description\">\n\t\t\t\t<?php echo __('Runs automatically once per hour. Recalculates contents of <code>podlove_download_intent_clean</code> table based on <code>podlove_download_intent</code> table. Clears cache. This is useful if you don\\'t get updated analytics or you played with data in <code>podlove_download_intent_clean</code> and messed up.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<?php\n        }, 'tracking-analytics');\n\n        $job_class = 'Podlove\\Jobs\\DownloadTimedAggregatorJob';\n        \\Podlove\\add_tools_field('ta-recalc-downloads-table', $job_class::title(), function () use ($job_class) {\n            $recent_job = Job::find_one_recent_job($job_class);\n            $recent_job_id = $recent_job ? $recent_job->id : ''; ?>\n\t\t\t<div\n\t\t\t\tclass=\"podlove-job\"\n\t\t\t\tdata-job=\"Podlove-Jobs-DownloadTimedAggregatorJob\"\n\t\t\t\tdata-args=\"<?php echo esc_attr(wp_json_encode(['force' => true])); ?>\"\n\t\t\t\tdata-button-text=\"<?php echo sprintf(__('Start %s', 'podlove-podcasting-plugin-for-wordpress'), $job_class::title()); ?>\"\n\t\t\t\tdata-recent-job-id=\"<?php echo $recent_job_id; ?>\"\n\t\t\t\t>\n\n\t\t\t</div>\n\n\t\t\t<p class=\"description\">\n\t\t\t\t<?php echo __('Runs automatically once per hour. Calculates total downloads per episode and downloads per episode in time segments (first day, first two days, ... first year) for the Analytics Dashboard.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</p>\n\t\t\t<?php\n        }, 'tracking-analytics');\n    }\n\n    public function process_actions()\n    {\n        if (filter_input(INPUT_GET, 'page') != 'podlove_tools_settings_handle') {\n            return;\n        }\n\n        switch (filter_input(INPUT_GET, 'action')) {\n            case 'clear_caches':\n                if (!current_user_can('administrator')) {\n                    exit;\n                }\n\n                if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_tools')) {\n                    http_response_code(401);\n                    exit;\n                }\n\n                \\Podlove\\Repair::clear_podlove_cache();\n                \\Podlove\\Repair::clear_podlove_image_cache();\n                wp_redirect(admin_url('admin.php?page='.$_REQUEST['page']));\n\n                break;\n\n            default:\n                // code...\n                break;\n        }\n    }\n\n    public function page()\n    {\n        wp_enqueue_script('podlove-tools-useragent', \\Podlove\\PLUGIN_URL.'/js/admin/tools/useragent.js', ['jquery'], \\Podlove\\get_plugin_header('Version'));\n\n        wp_enqueue_script('jquery-ui-progressbar'); ?>\n\n  <style>\n  .ui-progressbar {\n    position: relative;\n    margin-left: 225px;\n  }\n  .progress-label {\n    position: absolute;\n    left: 50%;\n    top: 4px;\n    font-weight: bold;\n    text-shadow: 1px 1px 0 #fff;\n  }\n\n  .progressbar-button {\n  \tfloat: left;\n  }\n\n.ui-progressbar {\n\theight: 2em;\n\ttext-align: left;\n\toverflow: hidden;\n}\n/*.ui-progressbar .ui-progressbar-value {\n\tmargin: -1px;\n\theight: 100%;\n}*/\n.ui-progressbar .ui-progressbar-overlay {\n\tbackground: url(\"data:image/gif;base64,R0lGODlhKAAoAIABAAAAAP///yH/C05FVFNDQVBFMi4wAwEAAAAh+QQJAQABACwAAAAAKAAoAAACkYwNqXrdC52DS06a7MFZI+4FHBCKoDeWKXqymPqGqxvJrXZbMx7Ttc+w9XgU2FB3lOyQRWET2IFGiU9m1frDVpxZZc6bfHwv4c1YXP6k1Vdy292Fb6UkuvFtXpvWSzA+HycXJHUXiGYIiMg2R6W459gnWGfHNdjIqDWVqemH2ekpObkpOlppWUqZiqr6edqqWQAAIfkECQEAAQAsAAAAACgAKAAAApSMgZnGfaqcg1E2uuzDmmHUBR8Qil95hiPKqWn3aqtLsS18y7G1SzNeowWBENtQd+T1JktP05nzPTdJZlR6vUxNWWjV+vUWhWNkWFwxl9VpZRedYcflIOLafaa28XdsH/ynlcc1uPVDZxQIR0K25+cICCmoqCe5mGhZOfeYSUh5yJcJyrkZWWpaR8doJ2o4NYq62lAAACH5BAkBAAEALAAAAAAoACgAAAKVDI4Yy22ZnINRNqosw0Bv7i1gyHUkFj7oSaWlu3ovC8GxNso5fluz3qLVhBVeT/Lz7ZTHyxL5dDalQWPVOsQWtRnuwXaFTj9jVVh8pma9JjZ4zYSj5ZOyma7uuolffh+IR5aW97cHuBUXKGKXlKjn+DiHWMcYJah4N0lYCMlJOXipGRr5qdgoSTrqWSq6WFl2ypoaUAAAIfkECQEAAQAsAAAAACgAKAAAApaEb6HLgd/iO7FNWtcFWe+ufODGjRfoiJ2akShbueb0wtI50zm02pbvwfWEMWBQ1zKGlLIhskiEPm9R6vRXxV4ZzWT2yHOGpWMyorblKlNp8HmHEb/lCXjcW7bmtXP8Xt229OVWR1fod2eWqNfHuMjXCPkIGNileOiImVmCOEmoSfn3yXlJWmoHGhqp6ilYuWYpmTqKUgAAIfkECQEAAQAsAAAAACgAKAAAApiEH6kb58biQ3FNWtMFWW3eNVcojuFGfqnZqSebuS06w5V80/X02pKe8zFwP6EFWOT1lDFk8rGERh1TTNOocQ61Hm4Xm2VexUHpzjymViHrFbiELsefVrn6XKfnt2Q9G/+Xdie499XHd2g4h7ioOGhXGJboGAnXSBnoBwKYyfioubZJ2Hn0RuRZaflZOil56Zp6iioKSXpUAAAh+QQJAQABACwAAAAAKAAoAAACkoQRqRvnxuI7kU1a1UU5bd5tnSeOZXhmn5lWK3qNTWvRdQxP8qvaC+/yaYQzXO7BMvaUEmJRd3TsiMAgswmNYrSgZdYrTX6tSHGZO73ezuAw2uxuQ+BbeZfMxsexY35+/Qe4J1inV0g4x3WHuMhIl2jXOKT2Q+VU5fgoSUI52VfZyfkJGkha6jmY+aaYdirq+lQAACH5BAkBAAEALAAAAAAoACgAAAKWBIKpYe0L3YNKToqswUlvznigd4wiR4KhZrKt9Upqip61i9E3vMvxRdHlbEFiEXfk9YARYxOZZD6VQ2pUunBmtRXo1Lf8hMVVcNl8JafV38aM2/Fu5V16Bn63r6xt97j09+MXSFi4BniGFae3hzbH9+hYBzkpuUh5aZmHuanZOZgIuvbGiNeomCnaxxap2upaCZsq+1kAACH5BAkBAAEALAAAAAAoACgAAAKXjI8By5zf4kOxTVrXNVlv1X0d8IGZGKLnNpYtm8Lr9cqVeuOSvfOW79D9aDHizNhDJidFZhNydEahOaDH6nomtJjp1tutKoNWkvA6JqfRVLHU/QUfau9l2x7G54d1fl995xcIGAdXqMfBNadoYrhH+Mg2KBlpVpbluCiXmMnZ2Sh4GBqJ+ckIOqqJ6LmKSllZmsoq6wpQAAAh+QQJAQABACwAAAAAKAAoAAAClYx/oLvoxuJDkU1a1YUZbJ59nSd2ZXhWqbRa2/gF8Gu2DY3iqs7yrq+xBYEkYvFSM8aSSObE+ZgRl1BHFZNr7pRCavZ5BW2142hY3AN/zWtsmf12p9XxxFl2lpLn1rseztfXZjdIWIf2s5dItwjYKBgo9yg5pHgzJXTEeGlZuenpyPmpGQoKOWkYmSpaSnqKileI2FAAACH5BAkBAAEALAAAAAAoACgAAAKVjB+gu+jG4kORTVrVhRlsnn2dJ3ZleFaptFrb+CXmO9OozeL5VfP99HvAWhpiUdcwkpBH3825AwYdU8xTqlLGhtCosArKMpvfa1mMRae9VvWZfeB2XfPkeLmm18lUcBj+p5dnN8jXZ3YIGEhYuOUn45aoCDkp16hl5IjYJvjWKcnoGQpqyPlpOhr3aElaqrq56Bq7VAAAOw==\");\n\theight: 100%;\n\tfilter: alpha(opacity=25); /* support: IE8 */\n\topacity: 0.25;\n}\n.ui-progressbar-indeterminate .ui-progressbar-value {\n\tbackground-image: none;\n}\n\n.podlove-recent-job-info {\n    display: inline-block;\n    line-height: 28px;\n    padding-left: 8px;\n    color: #666;\n}\n\n  </style>\n\n\t\t<div class=\"wrap\">\n\t\t\t<h2><?php echo __('Tools', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t\t\t<?php\n            $sections = \\Podlove\\get_tools_sections();\n        $fields = \\Podlove\\get_tools_fields(); ?>\n\n\t\t\t<?php foreach ($sections as $section_id => $section) { ?>\n\t\t\t\t<div class=\"card\" style=\"max-width: 100%\">\n\n\t\t\t\t\t<h3><?php echo $section['title']; ?></h3>\n\n\t\t\t\t\t<?php\n                    if (is_callable($section['callback'])) {\n                        call_user_func($section['callback']);\n                    } ?>\n\n\t\t\t\t\t<table class=\"form-table\">\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t<?php if (isset($fields[$section_id]) && is_array($fields[$section_id])) { ?>\n\t\t\t\t\t\t<?php foreach ($fields[$section_id] as $field_id => $field) { ?>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<th>\n\t\t\t\t\t\t\t\t\t<?php echo $field['title']; ?>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t<?php\n                                    if (is_callable($field['callback'])) {\n                                        call_user_func($field['callback']);\n                                    } ?>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t<?php } ?>\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t</div>\n\t\t\t<?php } ?>\n\n\t\t</div>\n\t\t<?php\n    }\n}\n"
  },
  {
    "path": "lib/shortcodes.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Provides a shortcode to display all available download links.\n *\n * Usage:\n *    [podlove-episode-downloads]\n *\n *  Attributes:\n *    style  \"buttons\" (default) - list of buttons\n *           \"select\" - html select list\n *\n * @param array $options\n *\n * @return string\n */\nfunction episode_downloads_shortcode($options)\n{\n    if (is_feed()) {\n        return '';\n    }\n\n    $defaults = ['style' => 'buttons'];\n    $attributes = shortcode_atts($defaults, $options);\n\n    if ($attributes['style'] === 'buttons') {\n        return \\Podlove\\Template\\TwigFilter::apply_to_html('@core/shortcode/downloads-buttons.twig');\n    }\n\n    return \\Podlove\\Template\\TwigFilter::apply_to_html('@core/shortcode/downloads-select.twig');\n}\nadd_shortcode('podlove-episode-downloads', '\\Podlove\\episode_downloads_shortcode');\n\n/**\n * Provides shortcode to display episode template.\n *\n * Usage:\n *\n *     [podlove-template template=\"Template Title\"]\n *\n *     Parameters:\n *         template: (required) Title of template to render.\n *         autop:    (optional) Wraps blocks of text in p tags. 'yes' or 'no'. Default: 'yes'\n *\n * @param array $attributes\n *\n * @return string\n */\nfunction template_shortcode($attributes)\n{\n    $defaults = [\n        'title' => '', // deprecated\n        'id' => '', // deprecated\n        'template' => '',\n        'autop' => false,\n    ];\n\n    $attributes = array_merge($defaults, $attributes);\n\n    if ($attributes['title'] !== '') {\n        _deprecated_argument(__FUNCTION__, '1.3.14-alpha', 'The \"title\" attribute for [podlove-template] shortcode is deprecated. Use \"id\" instead.');\n    }\n\n    if ($attributes['id'] !== '') {\n        _deprecated_argument(__FUNCTION__, '2.1.0', 'The \"id\" attribute for [podlove-template] shortcode is deprecated. Use \"template\" instead.');\n    }\n\n    // backward compatibility\n    if ($attributes['template']) {\n        $template_id = $attributes['template'];\n    } elseif ($attributes['id']) {\n        $template_id = $attributes['id'];\n    } else {\n        $template_id = $attributes['title'];\n    }\n\n    $permalink = get_permalink();\n\n    /**\n     * Cache key must be unique for *every permutation* of the content.\n     * Meaning: If there are context based conditionals, the key must reflect them.\n     */\n    $tag_permutation = implode('', array_map(function ($tag) {\n        return $tag() ? '1' : '0';\n    }, \\Podlove\\Template\\TwigFilter::$template_tags));\n\n    /**\n     * Cache key must change for any custom parameters.\n     */\n    $attr_permutation = implode('', array_map(function ($a) {\n        return (string) $a;\n    }, array_values($attributes)));\n\n    $cache_key = $template_id.$permalink.$tag_permutation.$attr_permutation;\n    $cache_key = apply_filters('podlove_template_shortcode_cache_key', $cache_key, $template_id);\n\n    $cache = \\Podlove\\Cache\\TemplateCache::get_instance();\n\n    return $cache->cache_for($cache_key, function () use ($template_id, $attributes) {\n        if (!$template = Model\\Template::find_one_by_title_with_fallback($template_id)) {\n            $safe_template_id = esc_html($template_id);\n\n            return sprintf(__('Podlove Error: Whoops, there is no template with id \"%s\"', 'podlove-podcasting-plugin-for-wordpress'), $safe_template_id);\n        }\n\n        $html = apply_filters('podlove_template_raw', $template->title, $attributes);\n\n        // apply autop and shortcodes\n        if (in_array($attributes['autop'], ['yes', 1, 'true'])) {\n            $html = wpautop($html);\n        }\n\n        return do_shortcode($html);\n    }, HOUR_IN_SECONDS);\n}\nadd_shortcode('podlove-template', '\\Podlove\\template_shortcode');\n\nadd_filter('podlove_template_raw', ['\\Podlove\\Template\\TwigFilter', 'apply_to_html'], 10, 2);\n\nfunction feed_list()\n{\n    return \\Podlove\\Template\\TwigFilter::apply_to_html('@core/shortcode/feed-list.twig');\n}\nadd_shortcode('podlove-feed-list', '\\Podlove\\feed_list');\n\nfunction episode_list()\n{\n    return \\Podlove\\Template\\TwigFilter::apply_to_html('@core/shortcode/episode-list.twig');\n}\nadd_shortcode('podlove-episode-list', '\\Podlove\\episode_list');\n"
  },
  {
    "path": "lib/slug_freeze.php",
    "content": "<?php\n\nnamespace Podlove;\n\nuse Podlove\\Model\\Episode;\nuse Podlove\\Model\\MediaFile;\n\n/**\n * Handles automatic slug freezing when media files validate successfully.\n */\nclass SlugFreeze\n{\n    public static function init()\n    {\n        // Hook into media file validation to automatically freeze slug\n        add_action('podlove_media_file_content_verified', [__CLASS__, 'maybe_freeze_slug_on_media_validation']);\n    }\n\n    /**\n     * Freeze episode slug when the first media file validates successfully.\n     *\n     * @param int $media_file_id\n     */\n    public static function maybe_freeze_slug_on_media_validation($media_file_id)\n    {\n        $media_file = MediaFile::find_by_id($media_file_id);\n\n        if (!$media_file || !$media_file->is_valid()) {\n            return;\n        }\n\n        $episode = Episode::find_by_id($media_file->episode_id);\n\n        if (!$episode || $episode->is_slug_frozen()) {\n            return;\n        }\n\n        // Only freeze if episode has a slug (avoid freezing empty slugs)\n        if (empty(trim($episode->slug))) {\n            return;\n        }\n\n        \\Podlove\\Log::get()->addInfo(\n            'Auto-freezing slug for episode',\n            ['episode_id' => $episode->id, 'slug' => $episode->slug]\n        );\n        $episode->freeze_slug();\n    }\n\n    /**\n     * Apply slug freezing logic to existing episodes with valid media files.\n     *\n     * This migration corrects existing episodes by freezing their slugs if they have\n     * valid media files, following the same logic as the automatic slug freeze feature.\n     */\n    public static function apply_slug_freeze_to_existing_episodes()\n    {\n        // Find all episodes that are not frozen and have non-empty slugs\n        $episodes = Model\\Episode::find_all_by_time();\n        $frozen_count = 0;\n\n        foreach ($episodes as $episode) {\n            // Check if episode has any valid media files\n            $media_files = $episode->media_files();\n            $has_valid_media = false;\n\n            foreach ($media_files as $media_file) {\n                if ($media_file->is_valid()) {\n                    $has_valid_media = true;\n\n                    break;\n                }\n            }\n\n            // If episode has valid media files, freeze the slug\n            if ($has_valid_media) {\n                $episode->freeze_slug();\n                ++$frozen_count;\n\n                \\Podlove\\Log::get()->addInfo(\n                    'Migration 162: Auto-freezing slug for existing episode',\n                    ['episode_id' => $episode->id, 'slug' => $episode->slug]\n                );\n            }\n        }\n\n        \\Podlove\\Log::get()->addInfo(\n            'Migration 162: Completed slug freeze migration',\n            ['episodes_processed' => count($episodes), 'episodes_frozen' => $frozen_count]\n        );\n    }\n}\n"
  },
  {
    "path": "lib/system_report.php",
    "content": "<?php\n\nnamespace Podlove;\n\nclass SystemReport\n{\n    private $fields = [];\n    private $notices = [];\n    private $errors = [];\n\n    public function __construct()\n    {\n        $errors = &$this->errors;\n        $notices = &$this->notices;\n\n        $this->fields = [\n            'site' => ['title' => 'Website', 'callback' => function () {\n                return get_site_url();\n            }],\n            'php_version' => ['title' => 'PHP Version', 'callback' => function () {\n                return phpversion();\n            }],\n            'wp_version' => ['title' => 'WordPress Version', 'callback' => function () {\n                return get_bloginfo('version');\n            }],\n            'theme' => ['title' => 'WordPress Theme', 'callback' => function () {\n                $theme = wp_get_theme();\n\n                return $theme->get('Name').' v'.$theme->get('Version');\n            },\n            ],\n            'active plugins' => ['title' => 'Active Plugins', 'callback' => function () {\n                $separator = \"\\n           - \";\n\n                return $separator.implode(\n                    $separator,\n                    array_map(\n                        function ($plugin_path) {\n                            $plugin = get_plugin_data(trailingslashit(WP_PLUGIN_DIR).$plugin_path);\n\n                            return sprintf('%s v%s', $plugin['Name'], $plugin['Version']);\n                        },\n                        get_option('active_plugins')\n                    )\n                );\n            }],\n            'db_charset' => ['title' => 'WordPress Database Charset', 'callback' => function () use (&$notices) {\n                // Fetch Episode Database Info from \"information_scheme\" Table\n                $db_connection = new \\wpdb(DB_USER, DB_PASSWORD, 'information_schema', DB_HOST);\n                $episode_database_info = $db_connection->get_row('SELECT * FROM `TABLES` WHERE `TABLE_SCHEMA` = \\''.DB_NAME.'\\' AND `TABLE_NAME` = \\''.\\Podlove\\Model\\Episode::table_name().'\\'', OBJECT);\n\n                if (is_object($episode_database_info) && !is_int(strpos($episode_database_info->TABLE_COLLATION, 'utf8'))) {\n                    $notices[] = 'Episode Database Charset is not UTF-8! (is '.$episode_database_info->TABLE_COLLATION.')';\n                }\n\n                $db_connection->close();\n\n                if (defined('DB_CHARSET')) {\n                    return DB_CHARSET;\n                }\n\n                return 'undefined constant DB_CHARSET';\n            }],\n            'db_collate' => ['title' => 'WordPress Database Collate', 'callback' => function () {\n                if (defined('DB_COLLATE')) {\n                    return DB_COLLATE;\n                }\n\n                return 'undefined constant DB_COLLATE';\n            }],\n            'podlove_version' => ['title' => 'Publisher Version', 'callback' => function () {\n                return \\Podlove\\get_plugin_header('Version');\n            }],\n            'podlove_database_version' => ['title' => 'Publisher Database Version', 'callback' => function () {\n                return get_option('podlove_database_version');\n            }],\n            'player_version' => ['title' => 'Web Player Version', 'callback' => function () {\n                return \\Podlove\\get_webplayer_setting('version');\n            }],\n            'open_basedir' => ['callback' => function () use (&$notices) {\n                $open_basedir = trim(ini_get('open_basedir'));\n\n                if ($open_basedir != '') {\n                    $notices[] = 'The PHP setting \"open_basedir\" is not empty. This is incompatible with curl, a library required by Podlove Publisher. We have a workaround in place but it is preferred to fix the issue. Please ask your hoster to unset \"open_basedir\".';\n                }\n\n                if ($open_basedir) {\n                    return $open_basedir;\n                }\n\n                return 'ok';\n            }],\n            'curl' => ['title' => 'curl Version', 'callback' => function () use (&$errors) {\n                $module_loaded = in_array('curl', get_loaded_extensions());\n                $function_disabled = stripos(ini_get('disable_functions'), 'curl_exec') !== false;\n                $out = '';\n\n                if ($module_loaded) {\n                    $curl = curl_version();\n                    $out .= $curl['version'];\n                } else {\n                    $out .= 'EXTENSION MISSING';\n                    $errors[] = 'curl extension is not loaded';\n                }\n\n                if ($function_disabled) {\n                    $out .= ' | curl_exec is disabled';\n                    $errors[] = 'curl_exec is disabled';\n                }\n\n                return $out;\n            }],\n            'iconv' => ['callback' => function () use (&$errors) {\n                $iconv_available = function_exists('iconv');\n\n                if (!$iconv_available) {\n                    $errors[] = 'You need to install/activate php5-iconv';\n                }\n\n                return $iconv_available ? 'available' : 'MISSING';\n            }],\n            'simplexml' => ['callback' => function () use (&$errors) {\n                if (!$simplexml = in_array('SimpleXML', get_loaded_extensions())) {\n                    $errors[] = 'You need to install/activate the PHP SimpleXML module';\n                }\n\n                return $simplexml ? 'ok' : 'missing!';\n            }],\n            'max_execution_time' => ['callback' => function () {\n                return ini_get('max_execution_time');\n            }],\n            'upload_max_filesize' => ['callback' => function () {\n                return ini_get('upload_max_filesize');\n            }],\n            'memory_limit' => ['callback' => function () {\n                return ini_get('memory_limit');\n            }],\n            'disable_classes' => ['callback' => function () {\n                return ini_get('disable_classes');\n            }],\n            'disable_functions' => ['callback' => function () {\n                return ini_get('disable_functions');\n            }],\n            'permalinks' => ['callback' => function () use (&$errors) {\n                $permalinks = \\get_option('permalink_structure');\n\n                if (!$permalinks) {\n                    $errors[] = sprintf(\n                        __('You are using the default WordPress permalink structure. This may cause problems with some podcast clients. Go to %s and set it to anything but default (for example \"Post name\").', 'podlove-podcasting-plugin-for-wordpress'),\n                        admin_url('options-permalink.php')\n                    );\n\n                    return __('\"non-pretty\" Permalinks: Please change permalink structure', 'podlove-podcasting-plugin-for-wordpress');\n                }\n\n                return \"ok ({$permalinks})\";\n            }],\n            'podlove_permalinks' => ['callback' => function () use (&$errors) {\n                if (\\Podlove\\get_setting('website', 'use_post_permastruct') == 'on') {\n                    return 'ok';\n                }\n\n                if (stristr(\\Podlove\\get_setting('website', 'custom_episode_slug'), '%podcast%') === false) {\n                    $website_options = get_option('podlove_website');\n                    $website_options['use_post_permastruct'] = 'on';\n                    update_option('podlove_website', $website_options);\n                }\n\n                return 'ok';\n            }],\n            'podcast_settings' => ['callback' => function () use (&$errors) {\n                $out = '';\n                $podcast = Model\\Podcast::get();\n\n                if (!$podcast->title) {\n                    $error = __('Your podcast needs a title.', 'podlove-podcasting-plugin-for-wordpress');\n                    $errors[] = $error;\n                    $out .= $error;\n                }\n\n                if (!$podcast->get_media_file_base_uri()) {\n                    $error = __('Your podcast needs an upload location for file storage.', 'podlove-podcasting-plugin-for-wordpress');\n                    $errors[] = $error;\n                    $out .= $error;\n                }\n\n                if (!$out) {\n                    $out = 'ok';\n                }\n\n                return $out;\n            }],\n            'web_player' => ['callback' => function () use (&$errors) {\n                foreach (get_option('podlove_webplayer_formats', []) as $_ => $media_types) {\n                    foreach ($media_types as $extension => $asset_id) {\n                        if ($asset_id) {\n                            return 'ok';\n                        }\n                    }\n                }\n\n                $error = __('You need to assign at least one asset to the web player.', 'podlove-podcasting-plugin-for-wordpress');\n                $errors[] = $error;\n\n                return $error;\n            }],\n            'podlove_cache' => ['callback' => function () {\n                return \\Podlove\\Cache\\TemplateCache::is_enabled() ? 'on' : 'off';\n            }],\n            'assets' => ['callback' => function () {\n                $assets = [];\n                foreach (\\Podlove\\Model\\EpisodeAsset::all() as $asset) {\n                    $file_type = $asset->file_type();\n                    $assets[] = [\n                        'extension' => $file_type->extension,\n                        'mime_type' => $file_type->mime_type,\n                        'feed' => Model\\Feed::find_one_by_episode_asset_id($asset->id),\n                    ];\n                }\n\n                return \"\\n&nbsp; - \".implode(\"\\n&nbsp; - \", array_map(function ($asset) {\n                    return str_pad($asset['extension'], 7).str_pad($asset['mime_type'], 17).($asset['feed'] ? $asset['feed']->get_subscribe_url() : 'no feed');\n                }, $assets));\n            }],\n            'cron' => [\n                'callback' => function () use (&$notices) {\n                    if (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON) {\n                        $notices[] = 'ALTERNATE_WP_CRON is active. This may sometimes cause failing downloads.';\n\n                        return 'ALTERNATE_WP_CRON active';\n                    }\n\n                    return 'ok';\n                },\n            ],\n            'duplicate_guids' => [\n                'callback' => function () use (&$errors) {\n                    $duplicates = \\Podlove\\Custom_Guid::find_duplicate_guids();\n                    if (count($duplicates)) {\n                        $message_base = 'Duplicate episode guids found. Fix as soon as possible as this will lead to trouble in podcast directories and podcast clients. Go to named episodes and use the \"regenerate\" function.';\n                        $message_dups = [];\n\n                        foreach ($duplicates as $guid => $post_ids) {\n                            $post_titles = array_map('get_the_title', $post_ids);\n                            $message_dups[] = \"guid \\\"{$guid}\\\" is used by: \".implode(', ', $post_titles);\n                        }\n\n                        $errors[] = $message_base.' '.implode('; ', $message_dups);\n\n                        return 'duplicate guids: '.count($duplicates);\n                    }\n\n                    return 'ok';\n                },\n            ],\n        ];\n\n        $this->fields = apply_filters('podlove_system_report_fields', $this->fields);\n\n        $this->run();\n    }\n\n    public function run()\n    {\n        $this->errors = [];\n        $this->notices = [];\n\n        foreach ($this->fields as $field_key => $field) {\n            $result = call_user_func($field['callback']);\n\n            if (is_array($result) && isset($result['message'])) {\n                $this->fields[$field_key]['value'] = $result['message'];\n                if (isset($result['error'])) {\n                    $this->errors[] = $result['error'];\n                }\n                if (isset($result['notice'])) {\n                    $this->notices[] = $result['notice'];\n                }\n            } else {\n                $this->fields[$field_key]['value'] = $result;\n            }\n        }\n\n        update_option('podlove_global_messages', ['errors' => $this->errors, 'notices' => $this->notices]);\n    }\n\n    public function render()\n    {\n        $rfill = function ($string, $length, $fillchar = ' ') {\n            while (strlen($string) < $length) {\n                $string .= $fillchar;\n            }\n\n            return $string;\n        };\n\n        $titles = array_map(function ($entry) {\n            return isset($entry['title']) ? $entry['title'] : '';\n        }, $this->fields);\n\n        $titles = array_merge(array_keys($titles), array_values($titles));\n\n        $fill_length = 1 + max(array_map(function ($k) {\n            return strlen($k);\n        }, $titles));\n\n        $out = '';\n\n        foreach ($this->fields as $field_key => $field) {\n            $title = isset($field['title']) ? $field['title'] : $field_key;\n            $out .= $rfill($title, $fill_length).$field['value'].\"\\n\";\n        }\n\n        $out .= \"\\n\";\n\n        if ($number_of_errors = count($this->errors)) {\n            $out .= sprintf(_n('%s ERROR', '%s ERRORS', $number_of_errors, 'podlove-podcasting-plugin-for-wordpress'), $number_of_errors);\n            $out .= \": \\n\";\n            foreach ($this->errors as $error) {\n                $out .= \"- {$error}\\n\";\n            }\n        } else {\n            $out .= \"0 errors\\n\";\n        }\n\n        if ($number_of_notices = count($this->notices)) {\n            $out .= sprintf(_n('%s NOTICE', '%s NOTICES', $number_of_notices, 'podlove-podcasting-plugin-for-wordpress'), $number_of_notices);\n            $out .= \" (no dealbreaker, but should be fixed if possible): \\n\";\n            foreach ($this->notices as $error) {\n                $out .= \"- {$error}\\n\";\n            }\n        } else {\n            $out .= \"0 notices\\n\";\n        }\n\n        if (count($this->errors) + count($this->notices) === 0) {\n            $out .= 'Nice, Everything looks fine!';\n        }\n\n        return $out;\n    }\n}\n"
  },
  {
    "path": "lib/template/asset.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Asset Template Wrapper.\n *\n * @templatetag asset\n */\nclass Asset extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\EpisodeAsset\n     */\n    private $asset;\n\n    public function __construct(\\Podlove\\Model\\EpisodeAsset $asset)\n    {\n        $this->asset = $asset;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->asset->title;\n    }\n\n    /**\n     * ID.\n     *\n     * @accessor\n     */\n    public function identifier()\n    {\n        return $this->asset->identifier;\n    }\n\n    /**\n     * Is the asset downloadable?\n     *\n     * @accessor\n     */\n    public function downloadable()\n    {\n        return (bool) $this->asset->downloadable;\n    }\n\n    /**\n     * File type.\n     *\n     * @see  file_type\n     *\n     * @accessor\n     */\n    public function fileType()\n    {\n        return new FileType($this->asset->file_type());\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->asset];\n    }\n}\n"
  },
  {
    "path": "lib/template/category.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Category Template Wrapper.\n *\n * @templatetag category\n */\nclass Category extends Wrapper\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    private $category;\n\n    public function __construct($category, $blog_id = null)\n    {\n        $this->category = $category;\n        $this->set_blog_id($blog_id);\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Term id.\n     *\n     * @accessor\n     */\n    public function id()\n    {\n        return $this->category->term_id;\n    }\n\n    /**\n     * Term Name.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->category->name;\n    }\n\n    /**\n     * Term Slug.\n     *\n     * @accessor\n     */\n    public function slug()\n    {\n        return $this->category->slug;\n    }\n\n    /**\n     * Term Description.\n     *\n     * @accessor\n     */\n    public function description()\n    {\n        return $this->category->description;\n    }\n\n    /**\n     * Term Count.\n     *\n     * @accessor\n     */\n    public function count()\n    {\n        return $this->category->count;\n    }\n\n    /**\n     * Term URL.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->with_blog_scope(function () {\n            return get_category_link($this->category->term_id);\n        });\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->category];\n    }\n}\n"
  },
  {
    "path": "lib/template/chapter.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Chapter Template Wrapper.\n *\n * @templatetag chapter\n */\nclass Chapter extends Wrapper\n{\n    /**\n     * @var \\Podlove\\Chapters\\Chapter\n     */\n    private $chapter;\n\n    public function __construct(\\Podlove\\Chapters\\Chapter $chapter)\n    {\n        $this->chapter = $chapter;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->chapter->get_title();\n    }\n\n    /**\n     * Link.\n     *\n     * @accessor\n     */\n    public function link()\n    {\n        return $this->chapter->get_link();\n    }\n\n    /**\n     * Image.\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return $this->chapter->get_image();\n    }\n\n    /**\n     * Time.\n     *\n     * @accessor\n     */\n    public function time()\n    {\n        return $this->chapter->get_time();\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->chapter];\n    }\n}\n"
  },
  {
    "path": "lib/template/date_time.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * DateTime Template Wrapper.\n *\n * @templatetag datetime\n */\nclass DateTime extends Wrapper\n{\n    private $timestamp;\n\n    public function __construct($timestamp)\n    {\n        $this->timestamp = (int) $timestamp;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Get date and time in default format.\n     *\n     * @accessor\n     */\n    public function __toString()\n    {\n        $format = get_option('date_format').' '.get_option('time_format');\n\n        return date_i18n($format, $this->timestamp);\n    }\n\n    /**\n     * Year.\n     *\n     * @accessor\n     */\n    public function year()\n    {\n        return date('Y', $this->timestamp);\n    }\n\n    /**\n     * Month number.\n     *\n     * @accessor\n     */\n    public function month()\n    {\n        return date('m', $this->timestamp);\n    }\n\n    /**\n     * Day of the month.\n     *\n     * @accessor\n     */\n    public function day()\n    {\n        return date('d', $this->timestamp);\n    }\n\n    /**\n     * Hours of the day, 24h format.\n     *\n     * @accessor\n     */\n    public function hours()\n    {\n        return date('H', $this->timestamp);\n    }\n\n    /**\n     * Minutes of the current hour.\n     *\n     * @accessor\n     */\n    public function minutes()\n    {\n        return date('i', $this->timestamp);\n    }\n\n    /**\n     * Seconds of the current minute.\n     *\n     * @accessor\n     */\n    public function seconds()\n    {\n        return date('s', $this->timestamp);\n    }\n\n    /**\n     * Custom time format.\n     *\n     * See [PHP date documentation](http://php.net/manual/en/function.date.php) for available formats\n     *\n     * @accessor\n     *\n     * @param mixed $format\n     */\n    public function format($format)\n    {\n        return date_i18n($format, $this->timestamp);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->timestamp];\n    }\n}\n"
  },
  {
    "path": "lib/template/duration.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Duration Template Wrapper.\n *\n * @templatetag duration\n */\nclass Duration extends Wrapper\n{\n    private $episode;\n\n    public function __construct(\\Podlove\\Model\\Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Get default duration display.\n     *\n     * @accessor\n     */\n    public function __toString()\n    {\n        if (!$this->totalMilliseconds()) {\n            return '00:00';\n        }\n\n        return $this->hours()\n             .':'.self::lfill($this->minutes(), 2, 0)\n             .':'.self::lfill($this->seconds(), 2, 0);\n    }\n\n    /**\n     * Hours.\n     *\n     * 0,1,2,…\n     *\n     * @accessor\n     */\n    public function hours()\n    {\n        return $this->episode->get_duration('hours');\n    }\n\n    /**\n     * Minutes.\n     *\n     * 0,1,2,…,59\n     *\n     * @accessor\n     */\n    public function minutes()\n    {\n        return $this->episode->get_duration('minutes');\n    }\n\n    /**\n     * Seconds.\n     *\n     * 0,1,2,…,59\n     *\n     * @accessor\n     */\n    public function seconds()\n    {\n        return $this->episode->get_duration('seconds');\n    }\n\n    /**\n     * Milliseconds.\n     *\n     * 0,1,2,…,999\n     *\n     * @accessor\n     */\n    public function milliseconds()\n    {\n        return $this->episode->get_duration('milliseconds');\n    }\n\n    /**\n     * The total duration in milliseconds.\n     *\n     * 0,1,2,…\n     *\n     * @accessor\n     */\n    public function totalMilliseconds()\n    {\n        return \\Podlove\\NormalPlayTime\\Parser::parse($this->episode->duration, 'ms');\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->episode];\n    }\n\n    /**\n     * Append characters to the left of the given string until a length is reached.\n     *\n     * @param string $string\n     * @param int    $length\n     * @param string $fillchar\n     *\n     * @return string\n     */\n    private static function lfill($string, $length, $fillchar = ' ')\n    {\n        while (strlen($string) < $length) {\n            $string = $fillchar.$string;\n        }\n\n        return $string;\n    }\n}\n"
  },
  {
    "path": "lib/template/episode.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Episode Template Wrapper.\n *\n * @templatetag episode\n */\nclass Episode extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\Episode\n     */\n    private $episode;\n\n    /**\n     * @var WP_Post\n     */\n    private $post;\n\n    public function __construct(\\Podlove\\Model\\Episode $episode)\n    {\n        $this->episode = $episode;\n        $this->post = $episode->post();\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * Returns the episode title, if set, otherwise the post title.\n     * If you want to access the post title directly, use `episode.post_title`.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return new EpisodeTitle($this->episode);\n    }\n\n    /**\n     * Post Title.\n     *\n     * Returns the episode post title. If automatic generation of post titles is enabled,\n     * the generated title is returned here.\n     *\n     * @accessor\n     */\n    public function post_title()\n    {\n        return $this->episode->post_title();\n    }\n\n    /**\n     * Subtitle.\n     *\n     * @accessor\n     */\n    public function subtitle()\n    {\n        // @todo generate warning if a shortcode is used in subtitles\n        return \\Podlove\\PHP\\escape_shortcodes($this->episode->subtitle);\n    }\n\n    /**\n     * Summary.\n     *\n     * @accessor\n     */\n    public function summary()\n    {\n        // @todo generate warning if a shortcode is used in summaries\n        return \\Podlove\\PHP\\escape_shortcodes($this->episode->summary);\n    }\n\n    /**\n     * Number.\n     *\n     * @accessor\n     */\n    public function number()\n    {\n        return $this->episode->number;\n    }\n\n    /**\n     * Type.\n     *\n     * One of: full, trailer, bonus\n     *\n     * @accessor\n     */\n    public function type()\n    {\n        return $this->episode->type;\n    }\n\n    /**\n     * Slug.\n     *\n     * @accessor\n     */\n    public function slug()\n    {\n        return $this->episode->slug;\n    }\n\n    /**\n     * Post content.\n     *\n     * @accessor\n     */\n    public function content()\n    {\n        return $this->post->post_content;\n    }\n\n    /**\n     * Podcast.\n     *\n     * @accessor\n     */\n    public function podcast()\n    {\n        return new \\Podlove\\Template\\Podcast(\n            \\Podlove\\Model\\Podcast::get($this->episode->get_blog_id())\n        );\n    }\n\n    /**\n     * Web Player for the current episode.\n     *\n     * The player should not appear in feeds, so embed it like this:\n     *\n     * ```jinja\n     * {% if not is_feed() %}\n     *   {{ episode.player }}\n     * {% endif %}\n     * ```\n     *\n     * For Podlove Web Player 5, you can set template and theme:\n     *\n     * ```jinja\n     * {{ episode.player({template: \"my-template\", theme: \"my-theme\"}) }}\n     * ```\n     *\n     * Or a specific episode by post id:\n     *\n     * ```jinja\n     * {{ episode.player({post_id: \"1234\"}) }}\n     * ```\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function player($args = [])\n    {\n        // fixme: \"publisher\" key is for pwp plugin, figure out what to do with post_id\n        $allowed_keys = ['template', 'config', 'theme', 'post_id', 'publisher', 'show'];\n\n        // pwp5\n        $args['publisher'] = $this->episode->post_id;\n        // other players\n        $args['post_id'] = $this->episode->post_id;\n\n        $shortcode_args = array_reduce(array_keys($args), function ($agg, $key) use ($args, $allowed_keys) {\n            if (in_array($key, $allowed_keys)) {\n                $agg[] = \"{$key}=\\\"\".esc_attr($args[$key]).'\"';\n            }\n\n            return $agg;\n        }, []);\n        $args_string = implode(' ', $shortcode_args);\n\n        return do_shortcode(\"[podlove-episode-web-player {$args_string}]\");\n    }\n\n    /**\n     * Post publication date.\n     *\n     * Uses WordPress datetime format by default or custom format: `{{ episode.publicationDate.format('Y-m-d') }}`\n     *\n     * @see  datetime\n     *\n     * @accessor\n     *\n     * @param mixed $format\n     */\n    public function publicationDate($format = '')\n    {\n        return new \\Podlove\\Template\\DateTime(strtotime($this->post->post_date), $format);\n    }\n\n    /**\n     * Post recording date.\n     *\n     * Uses WordPress datetime format by default or custom format: `{{ episode.recordingDate.format('Y-m-d') }}`\n     *\n     * @see  datetime\n     *\n     * @accessor\n     *\n     * @param mixed $format\n     */\n    public function recordingDate($format = '')\n    {\n        return new \\Podlove\\Template\\DateTime(strtotime($this->episode->recording_date), $format);\n    }\n\n    /**\n     * Explicit status.\n     *\n     * \"true\" or \"false\"\n     *\n     * @accessor\n     */\n    public function explicit()\n    {\n        return $this->episode->explicit_text();\n    }\n\n    /**\n     * URL.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->episode->permalink();\n    }\n\n    /**\n     * Duration Object.\n     *\n     * Use `duration` to display formatted hours, minutes and seconds.\n     * Alternatively, use the duration accessors for custom rendering.\n     *\n     * @see duration\n     *\n     * @accessor\n     */\n    public function duration()\n    {\n        return new Duration($this->episode);\n    }\n\n    /**\n     * WordPress WP_Post object.\n     *\n     * @accessor\n     */\n    public function post()\n    {\n        return $this->post;\n    }\n\n    /**\n     * Image.\n     *\n     * - fallback: `true` or `false`. Should the podcast image be used if no episode image is available? Default: `false`\n     *\n     * Example:\n     *\n     * ```jinja\n     * {{ episode.image({fallback: true}).url }}\n     * ```\n     *\n     * @see  image\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function image($args = [])\n    {\n        $defaults = ['fallback' => false];\n        $args = wp_parse_args($args, $defaults);\n\n        if ($args['fallback']) {\n            return new Image($this->episode->cover_art_with_fallback());\n        }\n        if ($cover_art = $this->episode->cover_art()) {\n            return new Image($cover_art);\n        }\n\n        return '';\n    }\n\n    /**\n     * Image URL.\n     *\n     * @deprecated since 2.2.0, use `episode.image.url` instead\n     *\n     * @accessor\n     */\n    public function imageUrl()\n    {\n        if ($cover_art = $this->episode->cover_art()) {\n            return new Image($cover_art);\n        }\n\n        return '';\n    }\n\n    /**\n     * Image URL with fallback.\n     *\n     * @deprecated since 2.2.0, use `episode.image({fallback: true}).url` instead\n     *\n     * @accessor\n     */\n    public function imageUrlWithFallback()\n    {\n        return new Image($this->episode->cover_art_with_fallback());\n    }\n\n    /**\n     * Total downloads.\n     *\n     * Please note that this value is only updated hourly.\n     *\n     * Example:\n     *\n     * ```html\n     * {{ episode.total_downloads | number_format(0, ',', '.') }}\n     * ```\n     *\n     * @accessor\n     */\n    public function total_downloads()\n    {\n        return $this->episode->meta('_podlove_downloads_total');\n    }\n\n    /**\n     * Access a single meta value.\n     *\n     * @accessor\n     *\n     * @param mixed $meta_key\n     */\n    public function meta($meta_key)\n    {\n        return $this->episode->meta($meta_key, true);\n    }\n\n    /**\n     * Access a list of meta values.\n     *\n     * Example:\n     *\n     * ```html\n     * <ul>\n     *   {% for meta in episode.metas(\"mymetakey\") %}\n     *     <li>{{ meta }}</li>\n     *   {% endfor %}\n     * </ul>\n     *\n     * {% for meta in episode.metas(\"mymetakey\") %}\n     *   {{ meta }}{% if not loop.last %}, {% endif %}\n     * {% endfor %}\n     * ```\n     *\n     * @accessor\n     *\n     * @param mixed $meta_key\n     */\n    public function metas($meta_key)\n    {\n        return $this->episode->meta($meta_key, false);\n    }\n\n    /**\n     * Access a list of post tags.\n     *\n     * See http://codex.wordpress.org/Function_Reference/wp_get_object_terms#Argument_Options\n     * for a list of available argument options.\n     *\n     * Example:\n     *\n     * ```html\n     *   {% for tag in episode.tags({order: \"ASC\", orderby: \"count\"}) %}\n     *     <a href=\"{{ tag.url }}\">{{ tag.name }} ({{ tag.count }})</a>\n     *   {% endfor %}\n     * ```\n     *\n     * @see  tag\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function tags($args = [])\n    {\n        return array_map(function ($tag) {\n            return new Tag($tag, $this->episode->get_blog_id());\n        }, $this->episode->tags($args));\n    }\n\n    /**\n     * Access a list of episode categories.\n     *\n     * See http://codex.wordpress.org/Function_Reference/wp_get_object_terms#Argument_Options\n     * for a list of available argument options.\n     *\n     * Requires the \"Categories\" module.\n     *\n     * Example:\n     *\n     * ```html\n     *   {% for category in episode.categories({order: \"ASC\", orderby: \"count\"}) %}\n     *     <a href=\"{{ category.url }}\">{{ category.name }} ({{ category.count }})</a>\n     *   {% endfor %}\n     * ```\n     *\n     * @see  category\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function categories($args = [])\n    {\n        return array_map(function ($category) {\n            return new Category($category, $this->episode->get_blog_id());\n        }, $this->episode->categories($args));\n    }\n\n    /**\n     * List of episode files.\n     *\n     * @see  file\n     *\n     * @accessor\n     */\n    public function files()\n    {\n        $files = array_filter($this->episode->media_files(), function ($file) {\n            return (bool) $file->active;\n        });\n\n        return array_map(function ($file) {\n            return new File($file);\n        }, $files);\n    }\n\n    /**\n     * One episode file by asset name.\n     *\n     * Example:\n     *\n     * ```jinja\n     * <a href=\"{{ episode.file(\"pdf\").publicUrl }}\">Download episode PDF</a>\n     * ```\n     *\n     * @see  file\n     *\n     * @accessor\n     *\n     * @param mixed $asset_name\n     */\n    public function file($asset_name)\n    {\n        $files = array_filter($this->episode->media_files(['identifier' => $asset_name]), function ($file) {\n            return (bool) $file->active;\n        });\n        $files = array_map(function ($file) {\n            return new File($file);\n        }, $files);\n\n        if ($files) {\n            return reset($files);\n        }\n\n        return null;\n    }\n\n    /**\n     * List of episode chapters.\n     *\n     * @see  chapter\n     *\n     * @accessor\n     */\n    public function chapters()\n    {\n        $chapters = $this->episode->get_chapters();\n\n        if (!$chapters) {\n            return [];\n        }\n\n        return array_map(function ($chapter) {\n            return new Chapter($chapter);\n        }, $chapters->toArray());\n    }\n\n    /**\n     * License.\n     *\n     * To render an HTML license, use `{% include '@core/license.twig' %}` for\n     * a license with fallback to the podcast license or\n     * `{% include '@core/license.twig' with {'license': episode.license} %}`\n     * for the episode license only.\n     *\n     * @see  license\n     *\n     * @accessor\n     */\n    public function license()\n    {\n        return new License(\n            new \\Podlove\\Model\\License(\n                'episode',\n                [\n                    'type' => $this->episode->license_type,\n                    'license_name' => $this->episode->license_name,\n                    'license_url' => $this->episode->license_url,\n                    'allow_modifications' => $this->episode->license_cc_allow_modifications,\n                    'allow_commercial_use' => $this->episode->license_cc_allow_commercial_use,\n                    'jurisdiction' => $this->episode->license_cc_license_jurisdiction,\n                ]\n            )\n        );\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->episode, $this->post];\n    }\n}\n"
  },
  {
    "path": "lib/template/episode_title.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Episode Title Wrapper.\n *\n * @templatetag duration\n */\nclass EpisodeTitle extends Wrapper\n{\n    private $episode;\n\n    public function __construct(\\Podlove\\Model\\Episode $episode)\n    {\n        $this->episode = $episode;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Get image URL.\n     *\n     * @accessor\n     */\n    public function __toString()\n    {\n        if ($this->clean()) {\n            return $this->clean();\n        }\n\n        return $this->blog();\n    }\n\n    /**\n     * Blog Title.\n     *\n     * The episode title as it appears in the blog.\n     *\n     * @accessor\n     */\n    public function blog()\n    {\n        return $this->episode->post_title();\n    }\n\n    /**\n     * Feed Title.\n     *\n     * The episode title as it appears in the feed.\n     *\n     * @accessor\n     */\n    public function clean()\n    {\n        return $this->episode->title;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->episode];\n    }\n}\n"
  },
  {
    "path": "lib/template/feed.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Feed Template Wrapper.\n *\n * @templatetag feed\n */\nclass Feed extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\Feed\n     */\n    private $feed;\n\n    public function __construct(\\Podlove\\Model\\Feed $feed)\n    {\n        $this->feed = $feed;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        if ($this->feed->title) {\n            return $this->feed->title;\n        }\n\n        return $this->feed->title_for_discovery();\n    }\n\n    /**\n     * URL.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->feed->get_subscribe_url();\n    }\n\n    /**\n     * Is the feed URL discoverable?\n     *\n     * @accessor\n     */\n    public function discoverable()\n    {\n        return (bool) $this->feed->discoverable;\n    }\n\n    /**\n     * Is the feed protected by a password?\n     *\n     * @accessor\n     */\n    public function passwordProtected()\n    {\n        return (bool) $this->feed->protected;\n    }\n\n    /**\n     * Asset.\n     *\n     * @see asset\n     *\n     * @accessor\n     */\n    public function asset()\n    {\n        return new Asset($this->feed->episode_asset());\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->feed];\n    }\n}\n"
  },
  {
    "path": "lib/template/file.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * File Template Wrapper.\n *\n * @templatetag file\n */\nclass File extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\MediaFile\n     */\n    private $file;\n\n    public function __construct(\\Podlove\\Model\\MediaFile $file)\n    {\n        $this->file = $file;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * File id.\n     *\n     * @accessor\n     */\n    public function id()\n    {\n        return $this->file->id;\n    }\n\n    /**\n     * Episode related to this file.\n     *\n     * @see  episode\n     *\n     * @accessor\n     */\n    public function episode()\n    {\n        return new Episode($this->file->episode());\n    }\n\n    /**\n     * Asset related to this file.\n     *\n     * @see  asset\n     *\n     * @accessor\n     */\n    public function asset()\n    {\n        return new Asset($this->file->episode_asset());\n    }\n\n    /**\n     * Size in bytes.\n     *\n     * @accessor\n     */\n    public function size()\n    {\n        return $this->file->size;\n    }\n\n    /**\n     * Is it active?\n     *\n     * @accessor\n     */\n    public function active()\n    {\n        return $this->file->active;\n    }\n\n    /**\n     * URL.\n     *\n     * The real file URL. For public facing URLs, use `.publicUrl`.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->file->get_file_url();\n    }\n\n    /**\n     * Public URL.\n     *\n     * If tracking is active, this generates the tracking URL.\n     * Otherwise, it's identical to `.url`.\n     *\n     * - source: download source for tracking, for example \"webplayer\", \"download\" or \"feed\"\n     * - context: (optional) download context for tracking, for example \"home\"/\"episode\"/\"archive\" for player source or feed slug for feed source\n     *\n     * **Examples**\n     *\n     * ```jinja\n     * {{ file.publicUrl('download', 'episode') }}\n     * ```\n     *\n     * @accessor\n     *\n     * @param mixed      $source\n     * @param null|mixed $context\n     */\n    public function publicUrl($source, $context = null)\n    {\n        return $this->file->get_public_file_url($source, $context);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->file];\n    }\n}\n"
  },
  {
    "path": "lib/template/file_type.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Filetype Template Wrapper.\n *\n * @templatetag file_type\n */\nclass FileType extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\FileType\n     */\n    private $fileType;\n\n    public function __construct(\\Podlove\\Model\\FileType $fileType)\n    {\n        $this->fileType = $fileType;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Name.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->fileType->name;\n    }\n\n    /**\n     * Type / group.\n     *\n     * One of those: audio, captions, chapters, ebook, image, metadata, video\n     *\n     * @accessor\n     */\n    public function type()\n    {\n        return $this->fileType->type;\n    }\n\n    /**\n     * Mimetype.\n     *\n     * @accessor\n     */\n    public function mimeType()\n    {\n        return $this->fileType->mime_type;\n    }\n\n    /**\n     * Extension.\n     *\n     * @accessor\n     */\n    public function extension()\n    {\n        return $this->fileType->extension;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->fileType];\n    }\n}\n"
  },
  {
    "path": "lib/template/image.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Episode Template Wrapper.\n *\n * @templatetag image\n */\nclass Image extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\Image\n     */\n    private $image;\n\n    public function __construct(\\Podlove\\Model\\Image $image)\n    {\n        $this->image = $image;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Get image URL.\n     *\n     * @accessor\n     */\n    public function __toString()\n    {\n        $url = $this->image->url();\n\n        return is_string($url) ? $url : '';\n    }\n\n    /**\n     * Get data-uri for resized image.\n     *\n     * **Parameters**\n     *\n     * see `url`\n     *\n     * **Examples**\n     *\n     * ```jinja\n     * {{ image.dataUri }}               {# returns the unresized image data URI #}\n     * {{ image.dataUri({width: 100}) }} {# returns resized image data URI #}\n     * <img src=\"{{ image.dataUri }}\" /> {# use it as img source #}\n     * ```\n     *\n     * **Return**\n     *\n     * The return value is a complete data uri like `data:image/png;base64,iVB...ggg==`.\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function dataUri($args = [])\n    {\n        $defaults = [\n            'width' => null,\n            'height' => null,\n            'crop' => false,\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        $file = $this->image\n            ->setCrop((bool) $args['crop'])\n            ->setWidth($args['width'])\n            ->setHeight($args['height'])\n            ->resized_file()\n        ;\n\n        if (!file_exists($file)) {\n            $this->image->download_source();\n            if ($args['width'] || $args['height']) {\n                $this->image->generate_resized_copy();\n            }\n        }\n\n        // fallback\n        if (!file_exists($file)) {\n            return $this->url($args);\n        }\n\n        $data = file_get_contents($file);\n        $data64 = base64_encode($data);\n\n        $finfo = new \\finfo(FILEINFO_MIME_TYPE);\n        $mime = $finfo->buffer($data);\n\n        return 'data:'.$mime.';base64,'.$data64;\n    }\n\n    /**\n     * Get URL for resized image.\n     *\n     * **Parameters**\n     *\n     * - width: Image width. Set width and leave height blank to keep the orinal aspect ratio.\n     * - height: Image height. Set height and leave width blank to keep the orinal aspect ratio.\n     * - crop: true or false. Crop image if given dimensions deviate from original aspect ratio. Default: false.\n     *\n     * **Examples**\n     *\n     * ```jinja\n     * {{ image.url }}               {# returns the unresized image URL #}\n     * {{ image.url({width: 100}) }} {# returns resized image URL #}\n     * ```\n     *\n     * Note: It is not _guaranteed_ to get back the resized image. If it is\n     * not ready yet, the source URL will be returned.\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function url($args = [])\n    {\n        $defaults = [\n            'width' => null,\n            'height' => null,\n            'crop' => false,\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        return $this->image\n            ->setCrop((bool) $args['crop'])\n            ->setWidth($args['width'])\n            ->setHeight($args['height'])\n            ->url()\n        ;\n    }\n\n    /**\n     * Get HTML image tag for resized image.\n     *\n     * **Parameters**\n     *\n     * - width: Image width. Set width and leave height blank to keep the orinal aspect ratio.\n     * - height: Image height. Set height and leave width blank to keep the orinal aspect ratio.\n     * - crop: true or false. Crop image if given dimensions deviate from original aspect ratio. Default: false.\n     * - id: Set image tag \"id\" attribute.\n     * - class: Set image tag \"class\" attribute.\n     * - style: Set image tag \"style\" attribute.\n     * - alt: Set image tag \"alt\" attribute.\n     * - title: Set image tag \"title\" attribute.\n     *\n     * **Examples**\n     *\n     * ```jinja\n     * {{ image.html }}                       {# returns the unresized image tag #}\n     * {{ image.html({width: 100}) }}         {# returns resized image tag #}\n     * {{ image.html({title: \"The Spark\"}) }} {# returns image tag with custom title #}\n     * ```\n     *\n     * Note: It is not _guaranteed_ to get back the resized image. If it is\n     * not ready yet, the source URL will be returned.\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function html($args = [])\n    {\n        $defaults = [\n            'width' => null,\n            'height' => null,\n            'crop' => false,\n            'id' => null,\n            'class' => null,\n            'style' => null,\n            'alt' => null,\n            'title' => null,\n            'attributes' => [],\n            'retina' => true,\n        ];\n        $args = wp_parse_args($args, $defaults);\n\n        return $this->image\n            ->setCrop((bool) $args['crop'])\n            ->setRetina((bool) $args['retina'])\n            ->setWidth($args['width'])\n            ->setHeight($args['height'])\n            ->image([\n                'id' => $args['id'],\n                'class' => $args['class'],\n                'style' => $args['style'],\n                'alt' => $args['alt'],\n                'title' => $args['title'],\n                'attributes' => $args['attributes'],\n            ])\n        ;\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->image];\n    }\n}\n"
  },
  {
    "path": "lib/template/license.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * License Template Wrapper.\n *\n * @templatetag license\n */\nclass License extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\License\n     */\n    private $license;\n\n    public function __construct(\\Podlove\\Model\\License $license)\n    {\n        $this->license = $license;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Name.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->license->getName();\n    }\n\n    /**\n     * URL.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->license->getUrl();\n    }\n\n    /**\n     * Image URL.\n     *\n     * @accessor\n     */\n    public function imageUrl()\n    {\n        if ($this->license->isCreativeCommons() == 'cc') {\n            return $this->license->getPictureUrl();\n        }\n\n        return '';\n    }\n\n    /**\n     * Is this a creative commons license?\n     *\n     * @accessor\n     */\n    public function creativeCommons()\n    {\n        return $this->license->isCreativeCommons();\n    }\n\n    /**\n     * Is the license valid? Is all required data available?\n     *\n     * @accessor\n     */\n    public function valid()\n    {\n        return $this->url() && $this->name();\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->license];\n    }\n}\n"
  },
  {
    "path": "lib/template/podcast.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Podcast Template Wrapper.\n *\n * @templatetag podcast\n */\nclass Podcast extends Wrapper\n{\n    /**\n     * @var Podlove\\Model\\Podcast\n     */\n    private $podcast;\n\n    public function __construct(\\Podlove\\Model\\Podcast $podcast)\n    {\n        $this->podcast = $podcast;\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Title.\n     *\n     * @accessor\n     */\n    public function title()\n    {\n        return $this->podcast->title;\n    }\n\n    /**\n     * Subtitle.\n     *\n     * @accessor\n     */\n    public function subtitle()\n    {\n        return $this->podcast->subtitle;\n    }\n\n    /**\n     * Summary.\n     *\n     * @accessor\n     */\n    public function summary()\n    {\n        return $this->podcast->summary;\n    }\n\n    /**\n     * Mnemonic / Abbreviation.\n     *\n     * @accessor\n     */\n    public function mnemonic()\n    {\n        return $this->podcast->mnemonic;\n    }\n\n    /**\n     * Type.\n     *\n     * One of: episodic, serial\n     *\n     * @accessor\n     */\n    public function type()\n    {\n        return $this->podcast->itunes_type;\n    }\n\n    /**\n     * Image URL.\n     *\n     * @deprecated since 2.2.0, use `image` instead\n     *\n     * @accessor\n     */\n    public function imageUrl()\n    {\n        return new Image($this->podcast->cover_art());\n    }\n\n    /**\n     * Image.\n     *\n     * @see  image\n     *\n     * @accessor\n     */\n    public function image()\n    {\n        return new Image($this->podcast->cover_art());\n    }\n\n    /**\n     * Author name.\n     *\n     * @accessor\n     */\n    public function authorName()\n    {\n        return $this->podcast->author_name;\n    }\n\n    /**\n     * Owner name.\n     *\n     * @accessor\n     */\n    public function ownerName()\n    {\n        return $this->podcast->owner_name;\n    }\n\n    /**\n     * Owner email.\n     *\n     * @accessor\n     */\n    public function ownerEmail()\n    {\n        return $this->podcast->owner_email;\n    }\n\n    /**\n     * Publisher name.\n     *\n     * @accessor\n     */\n    public function publisherName()\n    {\n        return $this->podcast->publisher_name;\n    }\n\n    /**\n     * Publisher URL.\n     *\n     * @accessor\n     */\n    public function publisherUrl()\n    {\n        return $this->podcast->publisher_url;\n    }\n\n    /**\n     * Podcast Home URL.\n     *\n     * @accessor\n     */\n    public function landingPageUrl()\n    {\n        return $this->podcast->landing_page_url();\n    }\n\n    /**\n     * Episodes.\n     *\n     * Filter and order episodes with parameters:\n     *\n     * - post_id: one episode matching the given post id\n     * - post_ids: list of episodes matching the given list of post ids\n     * - category: list of episodes matching the category slug\n     * - slug: one episode matching the given slug\n     * - slugs: list of episodes matching the given list of slugs\n     * - post_status: Publication status of the post. Defaults to 'publish'\n     * - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'.\n     *   - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c).\n     *   - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a).\n     * - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'.\n     *   - 'publicationDate' - Order by publication date.\n     *   - 'recordingDate' - Order by recording date.\n     *   - 'title' - Order by title.\n     *   - 'slug' - Order by episode slug.\n     *\t - 'limit' - Limit the number of returned episodes.\n     *\n     * **Examples**\n     *\n     * Iterate over all published episodes, ordered by publication date.\n     *\n     * ```\n     * {% for e in podcast.episodes %}\n     *   {{ e.title }}\n     * {% endfor %}\n     * ```\n     *\n     * Order by title in ascending order.\n     *\n     * ```\n     * {% for e in podcast.episodes({orderby: 'title', 'order': 'ASC'}) %}\n     *   {{ e.title }}\n     * {% endfor %}\n     * ```\n     *\n     * Fetch one episode by slug.\n     *\n     * ```\n     * {{ podcast.episodes({slug: 'pod001'}).title }}\n     * ```\n     *\n     * @see episode\n     *\n     * @accessor\n     *\n     * @param mixed $args\n     */\n    public function episodes($args = [])\n    {\n        $episodes = $this->podcast->episodes($args);\n\n        if (is_array($episodes)) {\n            return array_map(function ($episode) {\n                return new Episode($episode);\n            }, $episodes);\n        } else {\n            return new Episode($episodes);\n        }\n    }\n\n    /**\n     * Feeds.\n     *\n     * @see  feed\n     *\n     * @accessor\n     */\n    public function feeds()\n    {\n        return array_map(function ($feed) {\n            return new Feed($feed);\n        }, $this->podcast->feeds());\n    }\n\n    /**\n     * Single Feed by Slug/ID.\n     *\n     * **Example**\n     *\n     * ```\n     * {% set feed = podcast.feed(\"mp3\") %}\n     * The Feed: <a href=\"{{ feed.url }}\">{{ feed.title }}</a>\n     * ```\n     *\n     * @see  feed\n     *\n     * @accessor\n     *\n     * @param mixed $id\n     */\n    public function feed($id)\n    {\n        return new Feed(\\Podlove\\Model\\Feed::find_one_by_slug($id));\n    }\n\n    /**\n     * License.\n     *\n     * To render an HTML license, use\n     * `{% include '@core/license.twig' with {'license': podcast.license} %}`\n     *\n     * @see  license\n     *\n     * @accessor\n     */\n    public function license()\n    {\n        return new License(\n            new \\Podlove\\Model\\License(\n                'podcast',\n                [\n                    'license_name' => $this->podcast->license_name,\n                    'license_url' => $this->podcast->license_url,\n                ]\n            )\n        );\n    }\n\n    /**\n     * Get a podcast setting.\n     *\n     * Valid namespaces / names:\n     *\n     *  ```\n     *  website\n     *  \tmerge_episodes\n     *  \thide_wp_feed_discovery\n     *  \tuse_post_permastruct\n     *  \tcustom_episode_slug\n     *  \tepisode_archive\n     *  \tepisode_archive_slug\n     *  \turl_template\n     *  \tssl_verify_peer\n     *  metadata\n     *  \tenable_episode_recording_date\n     *  \tenable_episode_explicit\n     *  \tenable_episode_license\n     *  redirects\n     *  \tpodlove_setting_redirect\n     *  tracking\n     *  \tmode\n     *  ```\n     *\n     * @accessor\n     *\n     * @param mixed $namespace\n     * @param mixed $name\n     */\n    public function setting($namespace, $name)\n    {\n        return \\Podlove\\get_setting($namespace, $name);\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->podcast];\n    }\n}\n"
  },
  {
    "path": "lib/template/tag.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\n/**\n * Tag Template Wrapper.\n *\n * @templatetag tag\n */\nclass Tag extends Wrapper\n{\n    use \\Podlove\\Model\\KeepsBlogReferenceTrait;\n\n    private $tag;\n\n    public function __construct($tag, $blog_id = null)\n    {\n        $this->tag = $tag;\n        $this->set_blog_id($blog_id);\n    }\n\n    // /////////\n    // Accessors\n    // /////////\n\n    /**\n     * Term id.\n     *\n     * @accessor\n     */\n    public function id()\n    {\n        return $this->tag->term_id;\n    }\n\n    /**\n     * Term Name.\n     *\n     * @accessor\n     */\n    public function name()\n    {\n        return $this->tag->name;\n    }\n\n    /**\n     * Term Slug.\n     *\n     * @accessor\n     */\n    public function slug()\n    {\n        return $this->tag->slug;\n    }\n\n    /**\n     * Term Description.\n     *\n     * @accessor\n     */\n    public function description()\n    {\n        return $this->tag->description;\n    }\n\n    /**\n     * Term Count.\n     *\n     * @accessor\n     */\n    public function count()\n    {\n        return $this->tag->count;\n    }\n\n    /**\n     * Term URL.\n     *\n     * @accessor\n     */\n    public function url()\n    {\n        return $this->with_blog_scope(function () {\n            return get_tag_link($this->tag->term_id);\n        });\n    }\n\n    protected function getExtraFilterArgs()\n    {\n        return [$this->tag];\n    }\n}\n"
  },
  {
    "path": "lib/template/twig_date_extension.php",
    "content": "<?php\n\n/**\n * This file is part of Twig.\n *\n * (c) 2014-2019 Fabien Potencier\n * (c) 2022-2022 Stéphane Férey\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\nnamespace Podlove\\Template;\n\nuse DateTime;\nuse PodlovePublisher_Vendor\\Twig\\Environment;\nuse PodlovePublisher_Vendor\\Twig\\Extension\\AbstractExtension;\nuse PodlovePublisher_Vendor\\Twig\\TwigFilter;\n\n/**\n * @author Robin van der Vleuten <robinvdvleuten@gmail.com>\n */\nfinal class DateExtension extends AbstractExtension\n{\n    public static $units = [\n        'y' => 'year',\n        'm' => 'month',\n        'd' => 'day',\n        'h' => 'hour',\n        'i' => 'minute',\n        's' => 'second',\n    ];\n\n    public function getFilters(): array\n    {\n        return [\n            new TwigFilter('time_diff', [$this, 'diff'], ['needs_environment' => true]),\n        ];\n    }\n\n    /**\n     * Filters for converting dates to a time ago string like Facebook and Twitter has.\n     *\n     * @param \\DateTime|string $date a string or DateTime object to convert\n     * @param \\DateTime|string $now  A string or DateTime object to compare with. If none given, the current time will be used.\n     *\n     * @return string the converted time\n     */\n    public function diff(Environment $env, $date, $now = null): string\n    {\n        // Convert both dates to DateTime instances.\n        $date = \\PodlovePublisher_Vendor\\twig_date_converter($env, $date);\n        $now = \\PodlovePublisher_Vendor\\twig_date_converter($env, $now);\n\n        // Get the difference between the two DateTime objects.\n        $diff = $date->diff($now);\n\n        // Check for each interval if it appears in the $diff object.\n        foreach (self::$units as $attribute => $unit) {\n            $count = $diff->{$attribute};\n\n            if (0 !== $count) {\n                return $this->getPluralizedInterval($count, $diff->invert, $unit);\n            }\n        }\n\n        return '';\n    }\n\n    private function getPluralizedInterval(int $count, $invert, $unit): string\n    {\n        if (1 !== $count) {\n            $unit .= 's';\n        }\n\n        return $invert ? \"in {$count} {$unit}\" : \"{$count} {$unit} ago\";\n    }\n}\n"
  },
  {
    "path": "lib/template/twig_filter.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\nuse Podlove\\Model;\nuse PodlovePublisher_Vendor\\Twig;\n\n/**\n * Apply Twig functionality and podcast/episode accessors to strings/templates.\n *\n * Example:\n *     add_filter('some_filter_for_a_string', array('\\Podlove\\Template\\TwigFilter', 'apply_to_html'));\n *\n * @param string $html HTML string\n * @param array  $vars optional map of template variables\n */\nclass TwigFilter\n{\n    public static $template_tags = [\n        'is_archive',\n        'is_post_type_archive',\n        'is_attachment',\n        'is_tax',\n        'is_date',\n        'is_day',\n        'is_feed',\n        'is_comment_feed',\n        'is_front_page',\n        'is_home',\n        'is_month',\n        'is_page',\n        'is_paged',\n        'is_preview',\n        'is_search',\n        'is_single',\n        'is_singular',\n        'is_time',\n        'is_year',\n        'is_404',\n        'is_main_query',\n    ];\n\n    /**\n     * Apply Twig to given template.\n     *\n     * @param string $html file path or HTML string\n     * @param array  $vars optional variables for Twig context\n     *\n     * @return string rendered template string\n     */\n    public static function apply_to_html($html, $vars = [])\n    {\n        $twig = self::getTwigEnv();\n\n        $context = ['option' => $vars];\n\n        // add podcast to global context\n        $context = array_merge(\n            $context,\n            ['podcast' => new Podcast(Model\\Podcast::get())]\n        );\n\n        // Apply filters to twig templates\n        $context = apply_filters('podlove_templates_global_context', $context);\n\n        // add podcast to global context if we are in an episode\n        if ($episode = Model\\Episode::find_one_by_property('post_id', get_the_ID())) {\n            $context = array_merge($context, ['episode' => new Episode($episode)]);\n        }\n\n        $result = null;\n\n        if ($twig->getLoader()->exists($html)) {\n            try {\n                $result = $twig->render($html, $context);\n            } catch (Twig\\Error\\Error $e) {\n                $message = $e->getRawMessage();\n                $line = $e->getTemplateLine();\n                $template = $e->getSourceContext()->getName();\n\n                $result = 'Twig Error: '.$message.' (in template \"'.$template.'\" line '.$line.')';\n\n                \\Podlove\\Log::get()->addError($message, [\n                    'type' => 'twig',\n                    'line' => $line,\n                    'template' => $template,\n                ]);\n            }\n        }\n\n        if ($result === null) {\n            try {\n                // simple Twig Env to render plain string\n                $env = new Twig\\Environment(new Twig\\Loader\\ArrayLoader([]), ['autoescape' => false]);\n\n                // no clue yet how this is possible but it happens\n                if (method_exists($env, 'createTemplate')) {\n                    $template = $env->createTemplate($html);\n                    $result = $template->render($context);\n                } else {\n                    \\Podlove\\Log::get()->addError('Error when rendering Twig template from string. Missing Twig_Environment::createTemplate method.', [\n                        'type' => 'twig',\n                        'template' => $html,\n                    ]);\n                }\n            } catch (\\Exception $e) {\n                \\Podlove\\Log::get()->addError('Error when rendering Twig template from string: '.$e->getMessage(), [\n                    'type' => 'twig',\n                    'template' => $html,\n                ]);\n            }\n        }\n\n        return $result;\n    }\n\n    private static function getTwigLoader()\n    {\n        // file loader for internal use\n        $file_loader = new Twig\\Loader\\FilesystemLoader();\n        $file_loader->addPath(implode(DIRECTORY_SEPARATOR, [\\Podlove\\PLUGIN_DIR, 'templates']), 'core');\n\n        // other modules can register their own template directories/namespaces\n        $file_loader = apply_filters('podlove_twig_file_loader', $file_loader);\n\n        // database loader for user templates\n        $db_loader = new TwigLoaderPodloveDatabase();\n\n        $loaders = [$file_loader, $db_loader];\n        $loaders = apply_filters('podlove_twig_loaders', $loaders);\n\n        return new Twig\\Loader\\ChainLoader($loaders);\n    }\n\n    private static function getTwigEnv()\n    {\n        $twig = new Twig\\Environment(self::getTwigLoader(), ['autoescape' => false]);\n        $twig->addExtension(new DateExtension());\n\n        $formatBytesFilter = new Twig\\TwigFilter('formatBytes', function ($string) {\n            return \\Podlove\\format_bytes($string, 0);\n        });\n\n        $padLeftFilter = new Twig\\TwigFilter('padLeft', function ($string, $padChar, $length) {\n            while (strlen($string) < $length) {\n                $string = $padChar.$string;\n            }\n\n            return $string;\n        });\n\n        $wpautopFilter = new Twig\\TwigFilter('wpautop', function ($content) {\n            return \\wpautop($content);\n        });\n\n        $twig->addFilter($formatBytesFilter);\n        $twig->addFilter($padLeftFilter);\n        $twig->addFilter($wpautopFilter);\n\n        // add functions\n        foreach (self::$template_tags as $tag) {\n            $func = new Twig\\TwigFunction($tag, $tag);\n            $twig->addFunction($func);\n        }\n\n        $func = new Twig\\TwigFunction('get_the_post_thumbnail_url', function ($post = null, $size = 'post-thumbnail') {\n            return get_the_post_thumbnail_url($post, $size);\n        });\n        $twig->addFunction($func);\n\n        // shortcode_exists\n        $func = new Twig\\TwigFunction('shortcode_exists', function ($shortcode) {\n            return \\shortcode_exists($shortcode);\n        });\n        $twig->addFunction($func);\n\n        // Translation functions\n        $twig->addFunction(new Twig\\TwigFunction('__', function ($text, $domain = 'default') {\n            return \\__($text, $domain);\n        }));\n\n        $twig->addFunction(new Twig\\TwigFunction('_x', function ($text, $context, $domain = 'default') {\n            return \\_x($text, $context, $domain);\n        }));\n\n        $twig->addFunction(new Twig\\TwigFunction('_n', function ($single, $plural, $number, $domain = 'default') {\n            return \\_n($single, $plural, $number, $domain);\n        }));\n\n        $twig->addFunction(new Twig\\TwigFunction('_nx', function ($single, $plural, $number, $context, $domain = 'default') {\n            return \\_x($single, $plural, $number, $context, $domain);\n        }));\n\n        $policy = TwigSandbox::getSecurityPolicy();\n        $twig->addExtension(new Twig\\Extension\\SandboxExtension($policy, true));\n\n        return $twig;\n    }\n}\n"
  },
  {
    "path": "lib/template/twig_loader_podlove_database.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\nuse Podlove\\Model;\nuse PodlovePublisher_Vendor\\Twig;\n\nclass TwigLoaderPodloveDatabase implements Twig\\Loader\\LoaderInterface\n{\n    /**\n     * Returns the source context for a given template logical name.\n     *\n     * @param string $name The template logical name\n     *\n     * @throws Twig\\Error\\LoaderError When $name is not found\n     */\n    public function getSourceContext(string $name): Twig\\Source\n    {\n        if ($template = Model\\Template::find_one_by_title_with_fallback($name)) {\n            if ($template->content) {\n                return new Twig\\Source($template->content, $name);\n            }\n        }\n\n        throw new Twig\\Error\\LoaderError(\\sprintf('Unable to find the following template: \"%s\".', $name));\n    }\n\n    public function exists($name)\n    {\n        if (Model\\Template::find_one_by_title_with_fallback($name)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Gets the cache key to use for the cache for a given template name.\n     *\n     * @param string $name string The name of the template to load\n     *\n     * @return string The cache key\n     */\n    public function getCacheKey(string $name): string\n    {\n        return $name;\n    }\n\n    /**\n     * Returns true if the template is still fresh.\n     *\n     * @param string    $name The template name\n     * @param timestamp $time The last modification time of the cached template\n     */\n    public function isFresh(string $name, int $time): bool\n    {\n        return false;\n    }\n}\n"
  },
  {
    "path": "lib/template/twig_sandbox.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\nuse PodlovePublisher_Vendor\\Twig;\n\n/**\n * Configuration for Twig Sandbox.\n *\n * - allows all default Twig tags, filters and functions\n * - adds custom filters and functions which are defined in TwigFilter\n * - auto-generates allowlist for the Podlove Template API using reflection\n */\nclass TwigSandbox\n{\n    public static $allowed_custom_function_names = [\n        'get_the_post_thumbnail_url',\n        'shortcode_exists',\n        '__',\n        '_x',\n        '_n',\n        '_nx'\n    ];\n\n    public static $allowed_custom_filters = [\n        'formatBytes',\n        'padLeft',\n        'wpautop',\n    ];\n\n    public static $twig_tags = [\n        'apply',\n        'autoescape',\n        'block',\n        'cache',\n        'deprecated',\n        'do',\n        'embed',\n        'extends',\n        'flush',\n        'for',\n        'from',\n        'guard',\n        'if',\n        'import',\n        'include',\n        'macro',\n        'sandbox',\n        'set',\n        'types',\n        'use',\n        'verbatim',\n        'with',\n    ];\n\n    public static $twig_filters = [\n        'abs',\n        'batch',\n        'capitalize',\n        'column',\n        'convert_encoding',\n        'country_name',\n        'currency_name',\n        'currency_symbol',\n        'data_uri',\n        'date',\n        'date_modify',\n        'default',\n        'escape',\n        'filter',\n        'find',\n        'first',\n        'format',\n        'format_currency',\n        'format_date',\n        'format_datetime',\n        'format_number',\n        'format_time',\n        'html_to_markdown',\n        'inky_to_html',\n        'inline_css',\n        'join',\n        'json_encode',\n        'keys',\n        'language_name',\n        'last',\n        'length',\n        'locale_name',\n        'lower',\n        'map',\n        'markdown_to_html',\n        'merge',\n        'nl2br',\n        'number_format',\n        'plural',\n        'raw',\n        'reduce',\n        'replace',\n        'reverse',\n        'round',\n        'shuffle',\n        'singular',\n        'slice',\n        'slug',\n        'sort',\n        'spaceless',\n        'split',\n        'striptags',\n        'timezone_name',\n        'title',\n        'trim',\n        'u',\n        'upper',\n        'url_encode',\n    ];\n\n    public static $twig_functions = [\n        'attribute',\n        'block',\n        'constant',\n        'country_names',\n        'country_timezones',\n        'currency_names',\n        'cycle',\n        'date',\n        'dump',\n        'enum',\n        'enum_cases',\n        'html_classes',\n        'html_cva',\n        'include',\n        'language_names',\n        'locale_names',\n        'max',\n        'min',\n        'parent',\n        'random',\n        'range',\n        'script_names',\n        'source',\n        'template_from_string',\n        'timezone_names',\n    ];\n\n    public static $wp_post_props = ['WP_Post' => [\n        'ID',\n        'post_author',\n        'post_date',\n        'post_date_gmt',\n        'post_content',\n        'post_title',\n        'post_excerpt',\n        'post_status',\n        'comment_status',\n        'ping_status',\n        'post_name',\n        'to_ping',\n        'pinged',\n        'post_modified',\n        'post_modified_gmt',\n        'post_parent',\n        'guid',\n        'menu_order',\n        'post_type',\n        'post_mime_type',\n        'comment_count'\n    ]];\n\n    public static function getSecurityPolicy()\n    {\n        $filters = array_merge(\n            self::$twig_filters,\n            self::$allowed_custom_filters\n        );\n        $methods = self::get_podlove_template_methods();\n        $properties = self::$wp_post_props;\n        $functions = array_merge(\n            self::$twig_functions,\n            TwigFilter::$template_tags,\n            self::$allowed_custom_function_names\n        );\n\n        return new Twig\\Sandbox\\SecurityPolicy(self::$twig_tags, $filters, $methods, $properties, $functions);\n    }\n\n    private static function get_podlove_template_methods()\n    {\n        $classes = [\n            '\\Podlove\\Template\\Podcast',\n            '\\Podlove\\Template\\Feed',\n            '\\Podlove\\Template\\Episode',\n            '\\Podlove\\Template\\EpisodeTitle',\n            '\\Podlove\\Template\\Asset',\n            '\\Podlove\\Template\\File',\n            '\\Podlove\\Template\\Duration',\n            '\\Podlove\\Template\\Chapter',\n            '\\Podlove\\Template\\License',\n            '\\Podlove\\Template\\DateTime',\n            '\\Podlove\\Template\\FileType',\n            '\\Podlove\\Template\\Tag',\n            '\\Podlove\\Template\\Category',\n            '\\Podlove\\Template\\Image',\n            '\\Podlove\\Modules\\Contributors\\Template\\Avatar',\n            '\\Podlove\\Modules\\Contributors\\Template\\Contributor',\n            '\\Podlove\\Modules\\Contributors\\Template\\ContributorGroup',\n            '\\Podlove\\Modules\\Seasons\\Template\\Season',\n            '\\Podlove\\Modules\\Shows\\Template\\Show',\n            '\\Podlove\\Modules\\Social\\Template\\Service',\n            '\\Podlove\\Modules\\Networks\\Template\\Network',\n            '\\Podlove\\Modules\\Networks\\Template\\PodcastList',\n            '\\Podlove\\Modules\\Transcripts\\Template\\Line',\n            '\\Podlove\\Modules\\Transcripts\\Template\\Group',\n            '\\Podlove\\Modules\\Shownotes\\Template\\Entry'\n        ];\n\n        $dynamicAccessorClasses = [\n            '\\Podlove\\Modules\\Contributors\\TemplateExtensions',\n            '\\Podlove\\Modules\\Seasons\\TemplateExtensions',\n            '\\Podlove\\Modules\\RelatedEpisodes\\TemplateExtensions',\n            '\\Podlove\\Modules\\Shows\\TemplateExtensions',\n            '\\Podlove\\Modules\\Social\\TemplateExtensions',\n            '\\Podlove\\Modules\\SubscribeButton\\TemplateExtensions',\n            '\\Podlove\\Modules\\Transcripts\\TemplateExtensions',\n            '\\Podlove\\Modules\\Shownotes\\TemplateExtensions'\n        ];\n\n        $dynamicAccessors = [];\n        foreach ($dynamicAccessorClasses as $class) {\n            $reflectionClass = new \\ReflectionClass($class);\n            $methods = $reflectionClass->getMethods();\n\n            $accessors = array_filter($methods, function ($method) {\n                $comment = $method->getDocComment();\n\n                return stripos($comment, '@accessor') !== false && stripos($comment, '@dynamicAccessor') !== false;\n            });\n\n            $parsedMethods = array_map(function ($method) {\n                $c = new \\Podlove\\Comment\\Comment($method->getDocComment());\n                $c->parse();\n\n                $dynamicAccessor = $c->getTag('dynamicAccessor');\n                $callData = explode('.', $dynamicAccessor['description']);\n\n                return [\n                    'methodname' => $callData[1],\n                    'class' => $callData[0],\n                ];\n            }, $accessors);\n\n            foreach ($parsedMethods as $method) {\n                $class = match ($method['class']) {\n                    'episode' => 'Podlove\\Template\\Episode',\n                    'podcast' => 'Podlove\\Template\\Podcast',\n                    'contributor' => 'Podlove\\Modules\\Contributors\\Template\\Contributor',\n                    default => null\n                };\n\n                if ($class) {\n                    if (!isset($dynamicAccessors[$class])) {\n                        $dynamicAccessors[$class] = [];\n                    }\n\n                    $dynamicAccessors[$class][] = $method['methodname'];\n                }\n            }\n        }\n\n        return array_reduce($classes, function ($agg, $class) use ($dynamicAccessors) {\n            $reflectionClass = new \\ReflectionClass($class);\n            $className = $reflectionClass->getName();\n            $methods = $reflectionClass->getMethods();\n\n            $accessors = array_filter($methods, function ($method) {\n                $comment = $method->getDocComment();\n\n                return stripos($comment, '@accessor') !== false;\n            });\n\n            $parsedMethods = array_map(function ($method) {\n                return $method->name;\n            }, $accessors);\n\n            if (isset($dynamicAccessors[$className])) {\n                foreach ($dynamicAccessors[$className] as $dynamicMethod) {\n                    $parsedMethods[] = $dynamicMethod;\n                }\n            }\n\n            $agg[$className] = array_values($parsedMethods);\n\n            return $agg;\n        }, []);\n    }\n}\n"
  },
  {
    "path": "lib/template/wrapper.php",
    "content": "<?php\n\nnamespace Podlove\\Template;\n\nabstract class Wrapper\n{\n    /**\n     * List of accessors that were added dynamically.\n     *\n     * @var array\n     */\n    public static $dynamicAccessors = [];\n\n    public function __call($name, $arguments)\n    {\n        return apply_filters_ref_array(\n            static::get_magic_getter_filter_name($name),\n            array_merge([null, $name], $this->getExtraFilterArgs(), $arguments)\n        );\n    }\n\n    /**\n     * Dynamically add accessors to template wrappers.\n     *\n     * Example:\n     *\n     * Adding `{{ episode.summary }}` functionality\n     *\n     * ```\n     * \\Podlove\\Template\\Episode::add_accessor(\n     * \t'summary',\n     * \tfunction($return, $method_name, $episode, $post) {\n     * \t\treturn $episode->summary;\n     * \t}, 4\n     * );\n     * ```\n     *\n     * @param string   $name            accessor name\n     * @param function $method          accessor implementation\n     * @param int      $extraFilterArgs filter arguments length, defaults to 2\n     */\n    public static function add_accessor($name, $method, $extraFilterArgs = 2)\n    {\n        // implement the actual accessor\n        add_filter(\n            static::get_magic_getter_filter_name($name),\n            $method,\n            10,\n            $extraFilterArgs\n        );\n\n        if (!isset(static::$dynamicAccessors[static::get_class_slug()])) {\n            static::$dynamicAccessors[static::get_class_slug()] = [];\n        }\n\n        static::$dynamicAccessors[static::get_class_slug()][] = $name;\n    }\n\n    public static function get_class_slug()\n    {\n        $class = get_called_class();\n        $split = explode('\\\\', $class);\n\n        return strtolower(end($split));\n    }\n\n    public static function get_magic_getter_filter_name($name)\n    {\n        return 'podlove_template_'.static::get_class_slug().'_method_'.$name;\n    }\n\n    /**\n     * Override to pass extra arguments to filter methods.\n     *\n     * Adds arguments to the following filters:\n     * \t- podlove_template_<wrapper>_method\n     *\n     * @return array\n     */\n    abstract protected function getExtraFilterArgs();\n}\n"
  },
  {
    "path": "lib/tools.php",
    "content": "<?php\n\nnamespace Podlove;\n\n/**\n * Building the Publisher Tools page.\n *\n * API inspired by WP Settings API\n */\nfunction get_tools_sections()\n{\n    global $podlove_tools_sections;\n\n    return $podlove_tools_sections;\n}\n\nfunction get_tools_fields()\n{\n    global $podlove_tools_fields;\n\n    return $podlove_tools_fields;\n}\n\nfunction add_tools_section($id, $title, $callback = null)\n{\n    global $podlove_tools_sections;\n\n    $podlove_tools_sections[$id] = ['id' => $id, 'title' => $title, 'callback' => $callback];\n}\n\nfunction add_tools_field($id, $title, $callback, $section)\n{\n    global $podlove_tools_fields;\n\n    $podlove_tools_fields[$section][$id] = ['id' => $id, 'title' => $title, 'callback' => $callback];\n}\n"
  },
  {
    "path": "lib/tracking/debug.php",
    "content": "<?php\n\nnamespace Podlove\\Tracking;\n\nclass Debug\n{\n    public static function rewrites_exist()\n    {\n        global $wp_rewrite;\n\n        $top_rewrite_patterns = array_keys($wp_rewrite->extra_rules_top);\n        $podlove_rewrites = array_filter($top_rewrite_patterns, function ($pattern) {\n            return stristr($pattern, '^podlove/file/') !== false;\n        });\n\n        return count($podlove_rewrites) > 0;\n    }\n\n    public static function is_consistent_https_chain($public_url, $actual_url)\n    {\n        // if the site doesn't run SSL it doesn't matter what the actual_url structure is\n        if (!self::startswith($public_url, 'https')) {\n            return true;\n        }\n\n        // if the site runs SSL, the files *must* be served with SSL, too\n        return self::startswith($actual_url, 'https');\n    }\n\n    public static function url_resolves_correctly($start_url, $target_url)\n    {\n        $result = \\wp_remote_head($start_url, [\n            'user-agent' => \\Podlove\\Http\\Curl::user_agent()\n        ]);\n\n        if (\\is_wp_error($result)) {\n            return false;\n        }\n\n        $final_url = \\wp_remote_retrieve_header($result, 'location');\n\n        if (!$final_url) {\n            return false;\n        }\n\n        return stristr($final_url, $target_url) !== false;\n    }\n\n    private static function startswith($haystack, $needle)\n    {\n        return substr($haystack, 0, strlen($needle)) === $needle;\n    }\n}\n"
  },
  {
    "path": "lib/version.php",
    "content": "<?php\n\n/**\n * Version management for database migrations.\n *\n * Database changes require special care:\n * - the model has to be adjusted for users installing the plugin\n * - the current setup has to be migrated for current users\n *\n * These migrations are a way to handle current users. They do *not*\n * run on plugin activation.\n *\n * Pattern:\n *\n * - increment \\Podlove\\DATABASE_VERSION constant by 1, e.g.\n *         ```php\n *         define( __NAMESPACE__ . '\\DATABASE_VERSION', 2 );\n *         ```\n *\n * - add a case in `\\Podlove\\run_migrations_for_version`, e.g.\n *         ```php\n *         function run_migrations_for_version( $version ) {\n *            global $wpdb;\n *            switch ( $version ) {\n *                case 2:\n *                    $wbdb-> // run sql or whatever\n *                    break;\n *            }\n *        }\n *        ```\n *\n *        Feel free to move the migration code into a separate function if it's\n *        rather complex.\n *\n * - adjust the main model / setup process so new users installing the plugin\n *   will have these changes too\n *\n * - Test the migrations! :)\n */\n\nnamespace Podlove;\n\nuse Podlove\\Jobs\\CronJobRunner;\n\ndefine('Podlove\\DATABASE_VERSION', 165);\n\nadd_action('admin_init', '\\Podlove\\maybe_run_database_migrations');\nadd_action('admin_init', '\\Podlove\\run_database_migrations', 5);\n\nfunction maybe_run_database_migrations()\n{\n    $database_version = get_option('podlove_database_version');\n\n    if ($database_version === false) {\n        // plugin has just been installed\n        update_option('podlove_database_version', DATABASE_VERSION);\n    } elseif ($database_version < DATABASE_VERSION) {\n        wp_safe_redirect(admin_url('index.php?podlove_page=podlove_upgrade&_wp_http_referer='.urlencode(wp_unslash($_SERVER['REQUEST_URI']))));\n\n        exit;\n    }\n}\n\nfunction run_database_migrations()\n{\n    if (!isset($_REQUEST['podlove_page']) || $_REQUEST['podlove_page'] != 'podlove_upgrade') {\n        return;\n    }\n\n    if (get_option('podlove_database_version') >= DATABASE_VERSION) {\n        return;\n    }\n\n    if (is_multisite()) {\n        set_time_limit(0); // may take a while, depending on network size\n        \\Podlove\\for_every_podcast_blog(function () {\n            migrate_for_current_blog();\n        });\n    } else {\n        migrate_for_current_blog();\n    }\n\n    if (isset($_REQUEST['_wp_http_referer']) && $_REQUEST['_wp_http_referer']) {\n        wp_safe_redirect($_REQUEST['_wp_http_referer']);\n\n        exit;\n    }\n}\n\nfunction migrate_for_current_blog()\n{\n    $database_version = get_option('podlove_database_version');\n\n    for ($i = $database_version + 1; $i <= DATABASE_VERSION; ++$i) {\n        Log::get()->addInfo(sprintf('Migrate blog %d to version %d', get_current_blog_id(), $i));\n        \\Podlove\\run_migrations_for_version($i);\n        update_option('podlove_database_version', $i);\n    }\n\n    // flush rewrite rules after migrations\n    set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n    // purge cache after migrations\n    $cache = Cache\\TemplateCache::get_instance();\n    $cache->setup_purge();\n}\n\n/**\n * Find and run migration for given version number.\n *\n * @todo  move migrations into separate files\n *\n * @param int $version\n */\nfunction run_migrations_for_version($version)\n{\n    global $wpdb;\n\n    switch ($version) {\n        case 10:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `summary` TEXT',\n                Model\\Episode::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 11:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `downloadable` INT',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 12:\n            $sql = sprintf(\n                'UPDATE `%s` SET `downloadable` = 1',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 13:\n            $opus = ['name' => 'Opus Audio', 'type' => 'audio', 'mime_type' => 'audio/opus', 'extension' => 'opus'];\n            $f = new \\Podlove\\Model\\FileType();\n            foreach ($opus as $key => $value) {\n                $f->{$key} = $value;\n            }\n            $f->save();\n\n            break;\n        case 14:\n            $sql = sprintf(\n                'ALTER TABLE `%s` RENAME TO `%s`',\n                $wpdb->prefix.'podlove_medialocation',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 15:\n            $sql = sprintf(\n                'ALTER TABLE `%s` CHANGE `media_location_id` `episode_asset_id` INT',\n                Model\\MediaFile::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 16:\n            $sql = sprintf(\n                'ALTER TABLE `%s` CHANGE `media_location_id` `episode_asset_id` INT',\n                Model\\Feed::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 17:\n            $sql = sprintf(\n                'ALTER TABLE `%s` RENAME TO `%s`',\n                $wpdb->prefix.'podlove_mediaformat',\n                Model\\FileType::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 18:\n            $sql = sprintf(\n                'ALTER TABLE `%s` CHANGE `media_format_id` `file_type_id` INT',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 19:\n            Model\\Template::build();\n\n            break;\n        case 20:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `suffix` VARCHAR(255)',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n            $sql = sprintf(\n                'ALTER TABLE `%s` DROP COLUMN `url_template`',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 21:\n            $podcast = Model\\Podcast::get();\n            $podcast->url_template = '%media_file_base_url%%episode_slug%%suffix%.%format_extension%';\n            $podcast->save();\n\n            break;\n        case 22:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `redirect_http_status` INT AFTER `redirect_url`',\n                Model\\Feed::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 23:\n            $sql = sprintf(\n                'ALTER TABLE `%s` DROP COLUMN `show_description`',\n                Model\\Feed::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 24:\n            $podcast = Model\\Podcast::get();\n            update_option('podlove_asset_assignment', [\n                'image' => $podcast->supports_cover_art,\n                'chapters' => $podcast->chapter_file,\n            ]);\n\n            break;\n        case 25:\n            // rename meta podlove_guid to _podlove_guid\n            $episodes = Model\\Episode::all();\n            foreach ($episodes as $episode) {\n                $post = get_post($episode->post_id);\n\n                // skip revisions\n                if ($post->post_status == 'inherit') {\n                    continue;\n                }\n\n                $guid = get_post_meta($episode->post_id, 'podlove_guid', true);\n\n                if (!$guid) {\n                    $guid = $post->guid;\n                }\n\n                delete_post_meta($episode->post_id, 'podlove_guid');\n                update_post_meta($episode->post_id, '_podlove_guid', $guid);\n            }\n\n            break;\n        case 26:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` MODIFY COLUMN `subtitle` TEXT',\n                Model\\Episode::table_name()\n            ));\n\n            break;\n        case 27:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `record_date` DATETIME AFTER `chapters`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `publication_date` DATETIME AFTER `record_date`',\n                Model\\Episode::table_name()\n            ));\n\n            break;\n        case 28:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `position` FLOAT AFTER `downloadable`',\n                Model\\EpisodeAsset::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'UPDATE `%s` SET position = id',\n                Model\\EpisodeAsset::table_name()\n            ));\n\n            break;\n        case 29:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `embed_content_encoded` INT AFTER `limit_items`',\n                Model\\Feed::table_name()\n            ));\n\n            break;\n        case 30:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` MODIFY `autoinsert` VARCHAR(255)',\n                Model\\Template::table_name()\n            ));\n\n            break;\n        case 32:\n            flush_rewrite_rules();\n\n            break;\n        case 33:\n            $apd = ['name' => 'Auphonic Production Description', 'type' => 'metadata', 'mime_type' => 'application/json', 'extension' => 'json'];\n            $f = new \\Podlove\\Model\\FileType();\n            foreach ($apd as $key => $value) {\n                $f->{$key} = $value;\n            }\n            $f->save();\n\n            break;\n        case 34:\n            $options = get_option('podlove', []);\n            if (!array_key_exists('episode_archive', $options)) {\n                $options['episode_archive'] = 'on';\n            }\n\n            if (!array_key_exists('episode_archive_slug', $options)) {\n                $options['episode_archive_slug'] = '/podcast/';\n            }\n\n            if (!array_key_exists('use_post_permastruct', $options)) {\n                $options['use_post_permastruct'] = 'off';\n            }\n\n            if (!array_key_exists('custom_episode_slug', $options)) {\n                $options['custom_episode_slug'] = '/podcast/%podcast%/';\n            } else {\n                $options['custom_episode_slug'] = preg_replace('#/+#', '/', '/'.str_replace('#', '', $options['custom_episode_slug']));\n            }\n\n            update_option('podlove', $options);\n\n            break;\n        case 35:\n            Model\\Feed::build_indices();\n            Model\\FileType::build_indices();\n            Model\\EpisodeAsset::build_indices();\n            Model\\MediaFile::build_indices();\n            Model\\Episode::build_indices();\n            Model\\Template::build_indices();\n\n            break;\n        case 36:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `etag` VARCHAR(255)',\n                Model\\MediaFile::table_name()\n            ));\n\n            break;\n        case 37:\n            Modules\\Base::activate('asset_validation');\n\n            break;\n        case 38:\n            Modules\\Base::activate('logging');\n\n            break;\n        case 39:\n            // migrate previous template autoinsert settings\n            $assignments = Model\\TemplateAssignment::get_instance();\n            $results = $wpdb->get_results(\n                sprintf('SELECT * FROM `%s`', Model\\Template::table_name())\n            );\n\n            foreach ($results as $template) {\n                if ($template->autoinsert == 'beginning') {\n                    $assignments->top = $template->id;\n                } elseif ($template->autoinsert == 'end') {\n                    $assignments->bottom = $template->id;\n                }\n            }\n\n            $assignments->save();\n\n            // remove template autoinsert column\n            $sql = sprintf(\n                'ALTER TABLE `%s` DROP COLUMN `autoinsert`',\n                Model\\Template::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 40:\n            $wpdb->query(sprintf(\n                'UPDATE `%s` SET position = id WHERE position IS NULL',\n                Model\\EpisodeAsset::table_name()\n            ));\n\n            break;\n        case 41:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `position` FLOAT AFTER `slug`',\n                Model\\Feed::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'UPDATE `%s` SET position = id',\n                Model\\Feed::table_name()\n            ));\n\n            break;\n        case 42:\n            $wpdb->query(\n                'DELETE FROM `'.$wpdb->options.'` WHERE option_name LIKE \"%podlove_chapters_string_%\"'\n            );\n\n            break;\n        case 43:\n            $podlove_options = get_option('podlove', []);\n\n            $podlove_website = [\n                'merge_episodes' => isset($podlove_options['merge_episodes']) ? $podlove_options['merge_episodes'] : false,\n                'hide_wp_feed_discovery' => isset($podlove_options['hide_wp_feed_discovery']) ? $podlove_options['hide_wp_feed_discovery'] : false,\n                'use_post_permastruct' => isset($podlove_options['use_post_permastruct']) ? $podlove_options['use_post_permastruct'] : false,\n                'custom_episode_slug' => isset($podlove_options['custom_episode_slug']) ? $podlove_options['custom_episode_slug'] : '/episode/%podcast%',\n                'episode_archive' => isset($podlove_options['episode_archive']) ? $podlove_options['episode_archive'] : false,\n                'episode_archive_slug' => isset($podlove_options['episode_archive_slug']) ? $podlove_options['episode_archive_slug'] : '/podcast/',\n                'url_template' => isset($podlove_options['url_template']) ? $podlove_options['url_template'] : '%media_file_base_url%%episode_slug%%suffix%.%format_extension%',\n            ];\n            $podlove_metadata = [\n                'enable_episode_record_date' => isset($podlove_options['enable_episode_record_date']) ? $podlove_options['enable_episode_record_date'] : false,\n                'enable_episode_publication_date' => isset($podlove_options['enable_episode_publication_date']) ? $podlove_options['enable_episode_publication_date'] : false,\n            ];\n            $podlove_redirects = [\n                'podlove_setting_redirect' => isset($podlove_options['podlove_setting_redirect']) ? $podlove_options['podlove_setting_redirect'] : [],\n            ];\n\n            add_option('podlove_website', $podlove_website);\n            add_option('podlove_metadata', $podlove_metadata);\n            add_option('podlove_redirects', $podlove_redirects);\n\n            break;\n        case 44:\n            $wpdb->query(\n                'DELETE FROM `'.$wpdb->postmeta.'` WHERE meta_key = \"last_validated_at\"'\n            );\n\n            break;\n        case 45:\n            delete_transient('podlove_auphonic_user');\n            delete_transient('podlove_auphonic_presets');\n\n            break;\n        case 46:\n            if (Modules\\Base::is_active('contributors')) {\n                // manually trigger activation if the old module was active\n                $module = Modules\\Contributors\\Contributors::instance();\n                $module->was_activated('contributors');\n\n                // then, migrate existing contributors\n                // register old taxonomy so it can be queried\n                $args = [\n                    'hierarchical' => false,\n                    'labels' => [],\n                    'show_ui' => true,\n                    'show_tagcloud' => true,\n                    'query_var' => true,\n                    'rewrite' => ['slug' => 'contributor'],\n                ];\n\n                register_taxonomy('podlove-contributors', 'podcast', $args);\n                $contributor_settings = get_option('podlove_contributors', []);\n\n                $contributors = get_terms('podlove-contributors', ['hide_empty' => 0]);\n\n                if ($contributors && !is_wp_error($contributors) && Modules\\Contributors\\Model\\Contributor::count() == 0) {\n                    foreach ($contributors as $contributor) {\n                        // create new contributor\n                        $new = new \\Podlove\\Modules\\Contributors\\Model\\Contributor();\n                        $new->publicname = $contributor->name;\n                        $new->realname = $contributor->name;\n                        $new->slug = $contributor->slug;\n                        $new->showpublic = true;\n\n                        if (isset($contributor_settings[$contributor->term_id]['contributor_email'])) {\n                            $email = $contributor_settings[$contributor->term_id]['contributor_email'];\n                            if ($email) {\n                                $new->privateemail = $email;\n                                $new->avatar = $email;\n                            }\n                        }\n                        $new->save();\n\n                        // create contributions\n                        $query = new \\WP_Query([\n                            'posts_per_page' => -1,\n                            'post_type' => 'podcast',\n                            'tax_query' => [\n                                [\n                                    'taxonomy' => 'podlove-contributors',\n                                    'field' => 'slug',\n                                    'terms' => $contributor->slug,\n                                ],\n                            ],\n                        ]);\n                        while ($query->have_posts()) {\n                            $post = $query->next_post();\n                            $contribution = new \\Podlove\\Modules\\Contributors\\Model\\EpisodeContribution();\n                            $contribution->contributor_id = $new->id;\n                            $contribution->episode_id = Model\\Episode::find_one_by_post_id($post->ID)->id;\n                            $contribution->save();\n                        }\n                    }\n                }\n            }\n\n            break;\n        case 47:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `protected` TINYINT(1) NULL',\n                Model\\Feed::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `protection_type` TINYINT(1)',\n                Model\\Feed::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `protection_user` VARCHAR(60)',\n                Model\\Feed::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `protection_password` VARCHAR(64)',\n                Model\\Feed::table_name()\n            ));\n\n            break;\n        case 48:\n            $podcast = Model\\Podcast::get();\n            $podcast->limit_items = '-1';\n            $podcast->save();\n\n            break;\n        case 49:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `explicit` TINYINT',\n                Model\\Episode::table_name()\n            ));\n\n            break;\n        case 50:\n            $podcast = Model\\Podcast::get();\n            $podcast->license_type = 'other';\n            $podcast->save();\n\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_type` VARCHAR(255) AFTER `publication_date`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_name` TEXT AFTER `license_type`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_url` TEXT AFTER `license_name`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_cc_allow_modifications` TEXT AFTER `license_url`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_cc_allow_commercial_use` TEXT AFTER `license_cc_allow_modifications`',\n                Model\\Episode::table_name()\n            ));\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `license_cc_license_jurisdiction` TEXT AFTER `license_cc_allow_commercial_use`',\n                Model\\Episode::table_name()\n            ));\n\n            break;\n        case 51:\n            if (Modules\\Base::is_active('contributors')) {\n                Modules\\Contributors\\Model\\ContributorGroup::build();\n\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `group_id` VARCHAR(255) AFTER `role_id`',\n                    Modules\\Contributors\\Model\\EpisodeContribution::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `group_id` VARCHAR(255) AFTER `role_id`',\n                    Modules\\Contributors\\Model\\ShowContribution::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `paypal` VARCHAR(255) AFTER `flattr`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `bitcoin` VARCHAR(255) AFTER `paypal`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `litecoin` VARCHAR(255) AFTER `bitcoin`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` DROP COLUMN `permanentcontributor`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` DROP COLUMN `role`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n            }\n\n            break;\n        case 52:\n            if (Modules\\Base::is_active('contributors')) {\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `jobtitle` VARCHAR(255) AFTER `department`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n            }\n\n            break;\n        case 53:\n            // set all Episode as published (fix for ADN Module)\n            $episodes = Model\\Episode::all();\n            foreach ($episodes as $episode) {\n                $post = get_post($episode->post_id);\n                if ($post->post_status == 'publish') {\n                    update_post_meta($episode->post_id, '_podlove_episode_was_published', true);\n                }\n            }\n\n            break;\n        case 54:\n            if (Modules\\Base::is_active('contributors')) {\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `googleplus` TEXT AFTER `ADN`',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` CHANGE COLUMN `showpublic` `visibility` TINYINT(1)',\n                    Modules\\Contributors\\Model\\Contributor::table_name()\n                ));\n            }\n\n            break;\n        case 55:\n            if (Modules\\Base::is_active('contributors')) {\n                Modules\\Contributors\\Model\\DefaultContribution::build();\n\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `comment` TEXT AFTER `position`',\n                    Modules\\Contributors\\Model\\EpisodeContribution::table_name()\n                ));\n                $wpdb->query(sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `comment` TEXT AFTER `position`',\n                    Modules\\Contributors\\Model\\ShowContribution::table_name()\n                ));\n            }\n\n            break;\n        case 56:\n            // migrate Podcast Contributors to Default Contributors\n            if (Modules\\Base::is_active('contributors')) {\n                $podcast_contributors = Modules\\Contributors\\Model\\ShowContribution::all();\n                foreach ($podcast_contributors as $podcast_contributor_key => $podcast_contributor) {\n                    $new = new \\Podlove\\Modules\\Contributors\\Model\\DefaultContribution();\n                    $new->contributor_id = $podcast_contributor->contributor_id;\n                    $new->group_id = $podcast_contributor->group_id;\n                    $new->role_id = $podcast_contributor->role_id;\n                    $new->position = $podcast_contributor->positon;\n                    $new->save();\n                }\n            }\n\n            break;\n        case 57:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `append_name_to_podcast_title` TINYINT(1) NULL AFTER `embed_content_encoded`',\n                Model\\Feed::table_name()\n            ));\n\n            break;\n        case 58:\n            // if contributors module is active, activate social module\n            if (Modules\\Base::is_active('contributors')) {\n                Modules\\Base::activate('social');\n            }\n\n            break;\n        case 59:\n            if (Modules\\Base::is_active('bitlove')) {\n                $wpdb->query(sprintf(\n                    \"ALTER TABLE `%s` ADD COLUMN `bitlove` TINYINT(1) DEFAULT '0'\",\n                    Model\\Feed::table_name()\n                ));\n            }\n\n            break;\n        case 60:\n            Modules\\Base::activate('oembed');\n            Modules\\Base::activate('feed_validation');\n\n            break;\n        case 61:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` DROP COLUMN `publication_date`',\n                Model\\Episode::table_name()\n            ));\n\n            break;\n        case 62:\n            // rename column\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` CHANGE COLUMN `record_date` `recording_date` DATETIME',\n                Model\\Episode::table_name()\n            ));\n\n            // update settings\n            $meta = get_option('podlove_metadata');\n\n            if (isset($meta['enable_episode_publication_date'])) {\n                unset($meta['enable_episode_publication_date']);\n            }\n\n            if (isset($meta['enable_episode_record_date'])) {\n                $meta['enable_episode_recording_date'] = $meta['enable_episode_record_date'];\n                unset($meta['enable_episode_record_date']);\n            }\n\n            update_option('podlove_metadata', $meta);\n\n            break;\n        case 63:\n            if (Modules\\Base::is_active('social')) {\n                $tumblr_service = Modules\\Social\\Model\\Service::find_one_by_property('title', 'Tumblr');\n                $tumblr_service->url_scheme = 'http://%account-placeholder%.tumblr.com/';\n                $tumblr_service->save();\n            }\n\n            break;\n        case 64:\n            if (Modules\\Base::is_active('social')) {\n                $services = [\n                    [\n                        'title' => '500px',\n                        'type' => 'social',\n                        'description' => '500px Account',\n                        'logo' => '500px-128.png',\n                        'url_scheme' => 'https://500px.com/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'Last.fm',\n                        'type' => 'social',\n                        'description' => 'Last.fm Account',\n                        'logo' => 'lastfm-128.png',\n                        'url_scheme' => 'https://www.lastfm.de/user/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'OpenStreetMap',\n                        'type' => 'social',\n                        'description' => 'OpenStreetMap Account',\n                        'logo' => 'openstreetmap-128.png',\n                        'url_scheme' => 'https://www.openstreetmap.org/user/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'Soup',\n                        'type' => 'social',\n                        'description' => 'Soup Account',\n                        'logo' => 'soup-128.png',\n                        'url_scheme' => 'http://%account-placeholder%.soup.io',\n                    ],\n                ];\n\n                foreach ($services as $service_key => $service) {\n                    $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n                    $c->title = $service['title'];\n                    $c->type = $service['type'];\n                    $c->description = $service['description'];\n                    $c->logo = $service['logo'];\n                    $c->url_scheme = $service['url_scheme'];\n                    $c->save();\n                }\n            }\n\n            break;\n        case 65:\n            if (Modules\\Base::is_active('social')) {\n                $flattr_service = Modules\\Social\\Model\\Service::find_one_by_where(\"`title` = 'Flattr' AND `type` = 'donation'\");\n                if ($flattr_service) {\n                    $contributor_flattr_donations_accounts = Modules\\Social\\Model\\ContributorService::find_all_by_property('service_id', $flattr_service->id);\n\n                    foreach ($contributor_flattr_donations_accounts as $contributor_flattr_donations_account) {\n                        $contributor = Modules\\Contributors\\Model\\Contributor::find_by_id($contributor_flattr_donations_account->contributor_id);\n\n                        if ($contributor && is_null($contributor->flattr)) {\n                            $contributor->flattr = $contributor_flattr_donations_account->value;\n                            $contributor->save();\n                        }\n\n                        $contributor_flattr_donations_account->delete();\n                    }\n\n                    $flattr_service->delete();\n                }\n            }\n\n            break;\n        case 66:\n            // Temporary add license_type and CC license fields to episode model\n            Model\\Episode::property('license_type', 'VARCHAR(255)');\n            Model\\Episode::property('license_cc_allow_modifications', 'VARCHAR(255)');\n            Model\\Episode::property('license_cc_allow_commercial_use', 'VARCHAR(255)');\n            Model\\Episode::property('license_cc_license_jurisdiction', 'VARCHAR(255)');\n\n            $podcast = Model\\Podcast::get();\n            $episodes = Model\\Episode::all();\n\n            // Migration for Podcast\n            if (\n                $podcast->license_type == 'cc' && $podcast->license_cc_allow_commercial_use !== ''\n                && $podcast->license_cc_allow_modifications !== '' && $podcast->license_cc_license_jurisdiction !== ''\n            ) {\n                $license = [\n                    'version' => '3.0',\n                    'commercial_use' => $podcast->license_cc_allow_commercial_use,\n                    'modification' => $podcast->license_cc_allow_modifications,\n                    'jurisdiction' => $podcast->license_cc_license_jurisdiction,\n                ];\n\n                $podcast->license_url = Model\\License::get_url_from_license($license);\n                $podcast->license_name = Model\\License::get_name_from_license($license);\n\n                $podcast->save();\n            }\n\n            // Migration for Episodes\n            foreach ($episodes as $episode) {\n                if (\n                    $episode->license_type == 'other' || $episode->license_cc_allow_commercial_use == ''\n                    || $episode->license_cc_allow_modifications == '' || $episode->license_cc_license_jurisdiction == ''\n                ) {\n                    continue;\n                }\n\n                $license = [\n                    'version' => '3.0',\n                    'commercial_use' => $episode->license_cc_allow_commercial_use,\n                    'modification' => $episode->license_cc_allow_modifications,\n                    'jurisdiction' => $episode->license_cc_license_jurisdiction,\n                ];\n\n                $episode->license_url = Model\\License::get_url_from_license($license);\n                $episode->license_name = Model\\License::get_name_from_license($license);\n\n                $episode->save();\n            }\n\n            break;\n        case 67:\n            if (Modules\\Base::is_active('social')) {\n                $instagram_service = Modules\\Social\\Model\\Service::find_one_by_where(\"`title` = 'Instagram' AND `type` = 'social'\");\n                if ($instagram_service) {\n                    $instagram_service->url_scheme = 'https://instagram.com/%account-placeholder%';\n                    $instagram_service->save();\n                }\n            }\n\n            break;\n        case 68: // Do that ADN module fix again, as we forgot to mark all episodes as published if the ADN module is activated\n            $episodes = Model\\Episode::all();\n            foreach ($episodes as $episode) {\n                $post = get_post($episode->post_id);\n                if ($post->post_status == 'publish' && !get_post_meta($episode->post_id, '_podlove_episode_was_published', true)) {\n                    update_post_meta($episode->post_id, '_podlove_episode_was_published', true);\n                }\n            }\n\n            break;\n        case 69:\n            if (Modules\\Base::is_active('app_dot_net')) {\n                $adn = Modules\\AppDotNet\\App_Dot_Net::instance();\n                if ($adn->get_module_option('adn_auth_key')) {\n                    $adn->update_module_option('adn_automatic_announcement', 'on');\n                }\n            }\n\n            break;\n        case 70:\n            Model\\DownloadIntent::build();\n            Model\\UserAgent::build();\n\n            break;\n        case 71:\n            // update for everyone, so even those with inactive service tables get updated\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` CHANGE COLUMN `type` `category` VARCHAR(255)',\n                Modules\\Social\\Model\\Service::table_name()\n            ));\n\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `type` VARCHAR(255) AFTER `category`',\n                Modules\\Social\\Model\\Service::table_name()\n            ));\n\n            $services = Modules\\Social\\Model\\Service::all();\n            foreach ($services as $service) {\n                $service->type = strtolower($service->title);\n                $service->save();\n            }\n\n            break;\n        case 72:\n            if (Modules\\Base::is_active('social')) {\n                $services = [\n                    [\n                        'title' => 'Vimeo',\n                        'type' => 'vimeo',\n                        'category' => 'social',\n                        'description' => 'Vimeo Account',\n                        'logo' => 'vimeo-128.png',\n                        'url_scheme' => 'http://vimeo.com/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'about.me',\n                        'type' => 'about.me',\n                        'category' => 'social',\n                        'description' => 'about.me Account',\n                        'logo' => 'aboutme-128.png',\n                        'url_scheme' => 'http://about.me/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'Gittip',\n                        'type' => 'gittip',\n                        'category' => 'donation',\n                        'description' => 'Gittip Account',\n                        'logo' => 'gittip-128.png',\n                        'url_scheme' => 'https://www.gittip.com/%account-placeholder%',\n                    ],\n                ];\n\n                foreach ($services as $service_key => $service) {\n                    $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n                    $c->title = $service['title'];\n                    $c->type = $service['type'];\n                    $c->category = $service['category'];\n                    $c->description = $service['description'];\n                    $c->logo = $service['logo'];\n                    $c->url_scheme = $service['url_scheme'];\n                    $c->save();\n                }\n            }\n\n            break;\n        case 73:\n            if (Modules\\Base::is_active('social')) {\n                $jabber_service = Modules\\Social\\Model\\Service::find_one_by_where(\"`type` = 'jabber' AND `category` = 'social'\");\n                if ($jabber_service) {\n                    $jabber_service->url_scheme = 'jabber:%account-placeholder%';\n                    $jabber_service->save();\n                }\n            }\n\n            break;\n        case 74:\n            Model\\GeoArea::build();\n            Model\\GeoAreaName::build();\n            Geo_Ip::register_updater_cron();\n\n            break;\n        case 75:\n            $tracking = get_option('podlove_tracking');\n            $tracking['mode'] = 0;\n            update_option('podlove_tracking', $tracking);\n\n            break;\n        case 76:\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n            break;\n        case 77:\n            // delete empty user agents\n            $userAgentTable = Model\\UserAgent::table_name();\n            $downloadIntentTable = Model\\DownloadIntent::table_name();\n\n            $sql = \"SELECT\n\t\t\t\tdi.id\n\t\t\tFROM\n\t\t\t\t{$downloadIntentTable} di\n\t\t\t\tJOIN {$userAgentTable} ua ON ua.id = di.user_agent_id\n\t\t\tWHERE\n\t\t\t\tua.user_agent IS NULL\";\n            $ids = $wpdb->get_col($sql);\n\n            if (is_array($ids) && count($ids)) {\n                $sql = \"UPDATE {$downloadIntentTable} SET user_agent_id = NULL WHERE id IN (\".implode(',', $ids).')';\n                $wpdb->query($sql);\n\n                $sql = \"DELETE FROM {$userAgentTable} WHERE user_agent IS NULL\";\n                $wpdb->query($sql);\n            }\n\n            break;\n        case 78:\n            if (Modules\\Base::is_active('social')) {\n                $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n                $c->title = 'Auphonic Credits';\n                $c->category = 'donation';\n                $c->type = 'auphonic credits';\n                $c->description = 'Auphonic Account';\n                $c->logo = 'auphonic-128.png';\n                $c->url_scheme = 'https://auphonic.com/donate_credits?user=%account-placeholder%';\n                $c->save();\n            }\n\n            break;\n        case 79:\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n            $cache = Cache\\TemplateCache::get_instance();\n            $cache->setup_purge();\n\n            break;\n        case 80:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `httprange` VARCHAR(255)',\n                Model\\DownloadIntent::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 81:\n            // remove all caches with old namespace\n            $wpdb->query(\"DELETE FROM {$wpdb->options} WHERE option_name LIKE \\\"_transient_podlove_cache%\\\"\");\n\n            break;\n        case 82:\n            // set all redirect entries to active\n            $redirect_settings = \\Podlove\\get_setting('redirects', 'podlove_setting_redirect');\n            foreach ($redirect_settings as $index => $data) {\n                $redirect_settings[$index]['active'] = 'active';\n            }\n            update_option('podlove_redirects', ['podlove_setting_redirect' => $redirect_settings]);\n\n            break;\n        case 83:\n            Model\\DownloadIntentClean::build();\n\n            $alterations = [\n                'ALTER TABLE `%s` ADD COLUMN `bot` TINYINT',\n                'ALTER TABLE `%s` ADD COLUMN `client_name` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `client_version` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `client_type` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `os_name` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `os_version` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `device_brand` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `device_model` VARCHAR(255)',\n            ];\n\n            foreach ($alterations as $sql) {\n                $wpdb->query(sprintf($sql, Model\\UserAgent::table_name()));\n            }\n\n            Model\\UserAgent::reparse_all();\n\n            break;\n        case 84:\n            delete_option('podlove_tpl_cache_keys');\n\n            break;\n        case 85:\n            add_option('podlove_tracking_delete_head_requests', 1);\n\n            break;\n        case 86:\n            if (Modules\\Base::is_active('social')) {\n                $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n                $c->title = 'Foursquare';\n                $c->category = 'social';\n                $c->type = 'foursquare';\n                $c->description = 'Foursquare Account';\n                $c->logo = 'foursquare-128.png';\n                $c->url_scheme = 'https://foursquare.com/%account-placeholder%';\n                $c->save();\n\n                $services = [\n                    [\n                        'title' => 'ResearchGate',\n                        'name' => 'researchgate',\n                        'category' => 'social',\n                        'description' => 'ResearchGate URL',\n                        'logo' => 'researchgate-128.png',\n                        'url_scheme' => '%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'ORCiD',\n                        'name' => 'orcid',\n                        'category' => 'social',\n                        'description' => 'ORCiD',\n                        'logo' => 'orcid-128.png',\n                        'url_scheme' => 'https://orcid.org/%account-placeholder%',\n                    ],\n                    [\n                        'title' => 'Scopus',\n                        'name' => 'scous',\n                        'category' => 'social',\n                        'description' => 'Scopus Author ID',\n                        'logo' => 'scopus-128.png',\n                        'url_scheme' => 'https://www.scopus.com/authid/detail.url?authorId=%account-placeholder%',\n                    ],\n                ];\n\n                foreach ($services as $service_key => $service) {\n                    $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n                    $c->title = $service['title'];\n                    $c->category = $service['category'];\n                    $c->type = $service['name'];\n                    $c->description = $service['description'];\n                    $c->logo = $service['logo'];\n                    $c->url_scheme = $service['url_scheme'];\n                    $c->save();\n                }\n            }\n\n            break;\n        case 87:\n            if (Modules\\Base::is_active('app_dot_net')) {\n                $adn = Modules\\AppDotNet\\App_Dot_Net::instance();\n                if ($adn->get_module_option('adn_auth_key')) {\n                    $adn->update_module_option('adn_poster_image_fallback', 'on');\n                }\n            }\n\n            break;\n        case 88:\n            $service = new \\Podlove\\Modules\\Social\\Model\\Service();\n            $service->title = 'Email';\n            $service->category = 'social';\n            $service->type = 'email';\n            $service->description = 'Email';\n            $service->logo = 'email-128.png';\n            $service->url_scheme = 'mailto:%account-placeholder%';\n            $service->save();\n\n            break;\n        case 89:\n            $email_service = Modules\\Social\\Model\\Service::find_one_by_type('email');\n\n            foreach (Modules\\Contributors\\Model\\Contributor::all() as $contributor) {\n                if (!$contributor->publicemail) {\n                    continue;\n                }\n\n                $contributor_service = new \\Podlove\\Modules\\Social\\Model\\ContributorService();\n                $contributor_service->contributor_id = $contributor->id;\n                $contributor_service->service_id = $email_service->id;\n                $contributor_service->value = $contributor->publicemail;\n                $contributor_service->save();\n            }\n\n            break;\n        case 90:\n            Modules\\Base::activate('subscribe_button');\n\n            break;\n        case 91:\n            $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n            $c->title = 'Miiverse';\n            $c->category = 'social';\n            $c->type = 'miiverse';\n            $c->description = 'Miiverse Account';\n            $c->logo = 'miiverse-128.png';\n            $c->url_scheme = 'https://miiverse.nintendo.net/users/%account-placeholder%';\n            $c->save();\n\n            break;\n        case 92:\n            $c = new \\Podlove\\Modules\\Social\\Model\\Service();\n            $c->title = 'Prezi';\n            $c->category = 'social';\n            $c->type = 'prezi';\n            $c->description = 'Prezis';\n            $c->logo = 'prezi-128.png';\n            $c->url_scheme = 'http://prezi.com/user/%account-placeholder%';\n            $c->save();\n\n            break;\n        case 93:\n            // podlove_init_user_agent_refresh();\n            // do nothing instead, because see 94 below\n            break;\n        case 94:\n            // this is a duplicate of migration 83 but it looks like that didn't work.\n            Model\\DownloadIntentClean::build();\n\n            $alterations = [\n                'ALTER TABLE `%s` ADD COLUMN `bot` TINYINT',\n                'ALTER TABLE `%s` ADD COLUMN `client_name` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `client_version` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `client_type` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `os_name` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `os_version` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `device_brand` VARCHAR(255)',\n                'ALTER TABLE `%s` ADD COLUMN `device_model` VARCHAR(255)',\n            ];\n\n            foreach ($alterations as $sql) {\n                $wpdb->query(sprintf($sql, Model\\UserAgent::table_name()));\n            }\n\n            // podlove_init_user_agent_refresh();\n\n            // manually trigger intent cron after user agents are parsed\n            // parameter to make sure WP does not skip it due to 10 minute rule\n            wp_schedule_single_event(time() + 120, 'podlove_cleanup_download_intents', ['really' => true]);\n            // manually trigger average cron after intents are calculated\n            wp_schedule_single_event(time() + 240, 'recalculate_episode_download_average', ['really' => true]);\n\n            break;\n        case 95:\n            // add missing flattr column\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `flattr` VARCHAR(255) AFTER `avatar`',\n                Modules\\Contributors\\Model\\Contributor::table_name()\n            ));\n\n            break;\n        case 96:\n            DeleteHeadRequests::init();\n\n            break;\n        case 97:\n            // recalculate all downloads average data\n            $wpdb->query(sprintf(\n                'DELETE FROM `%s` WHERE `meta_key` LIKE \"_podlove_eda%%\"',\n                $wpdb->postmeta\n            ));\n\n            break;\n        case 98:\n            delete_transient('podlove_dashboard_stats_contributors');\n\n            break;\n        case 99:\n            // Activate network module for migrating users.\n            // Core modules are automatically activated for _new_ setups and\n            // whenever modules change. Since this can't be guaranteed for\n            // existing setups, it must be triggered manually.\n            Modules\\Networks\\Networks::instance()->was_activated();\n\n            break;\n        case 101:\n            // add patreon\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\RepairSocial::fix_missing_services();\n            }\n\n            break;\n        case 102:\n            // update logos\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n            }\n\n            break;\n        case 103:\n            $assignment = get_option('podlove_template_assignment', []);\n\n            if ($assignment['top'] && is_numeric($assignment['top'])) {\n                $assignment['top'] = Model\\Template::find_by_id($assignment['top'])->title;\n            }\n\n            if ($assignment['bottom'] && is_numeric($assignment['bottom'])) {\n                $assignment['bottom'] = Model\\Template::find_by_id($assignment['bottom'])->title;\n            }\n\n            update_option('podlove_template_assignment', $assignment);\n\n            break;\n        case 104:\n            \\Podlove\\unschedule_events(Cache\\TemplateCache::CRON_PURGE_HOOK);\n\n            break;\n        case 105:\n            // activate flattr plugin\n            Modules\\Base::activate('flattr');\n\n            // migrate flattr data\n            $podcast = Model\\Podcast::get();\n            $settings = get_option('podlove_flattr', []);\n            $settings['account'] = $podcast->flattr;\n            $settings['contributor_shortcode_default'] = 'yes';\n            update_option('podlove_flattr', $settings);\n\n            break;\n        case 106:\n            // podlove_init_user_agent_refresh();\n            break;\n        case 107:\n            // skipped\n            break;\n        case 108:\n            // podlove_init_user_agent_refresh();\n            break;\n        case 109:\n            \\podlove_init_capabilities();\n\n            break;\n        case 110:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 111:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 112:\n            // if any feed is protected, activate protection module\n            $should_activate_protection_module = false;\n            foreach (Model\\Feed::all() as $feed) {\n                if ($feed->protected) {\n                    $should_activate_protection_module = true;\n                }\n            }\n\n            if ($should_activate_protection_module) {\n                Modules\\Base::activate('protected_feed');\n            }\n\n            break;\n        case 113:\n            delete_option('podlove_jobs');\n            Model\\Job::build();\n\n            break;\n        case 114:\n            $alterations = [\n                'ALTER TABLE `%s` ADD COLUMN `wakeups` INT',\n                'ALTER TABLE `%s` ADD COLUMN `sleeps` INT',\n            ];\n\n            foreach ($alterations as $sql) {\n                $wpdb->query(sprintf($sql, Model\\Job::table_name()));\n            }\n\n            break;\n        case 115:\n            Model\\Job::delete_all();\n\n            break;\n        case 116:\n            // \"clean slate\" analytics calculation\n\n            // first, ensure no jobs with wrong parameters are already setup\n            Model\\Job::delete_all();\n\n            // then queue all analytics jobs\n            $jobs = [\n                '\\Podlove\\Jobs\\UserAgentRefreshJob' => [],\n                '\\Podlove\\Jobs\\DownloadIntentCleanupJob' => ['delete_all' => true],\n                '\\Podlove\\Jobs\\DownloadTimedAggregatorJob' => ['force' => true],\n            ];\n\n            foreach ($jobs as $job => $args) {\n                CronJobRunner::create_job($job, $args);\n            }\n\n            break;\n        case 117:\n            $sql = 'DELETE FROM `'.Model\\Job::table_name().'` WHERE `class` LIKE \"%DownloadTotalsAggregatorJob\"';\n            $wpdb->query($sql);\n\n            break;\n        case 118:\n            // unschedule podlove_calc_download_sums cron because the interval changed from twicedaily to hourly\n            if (wp_next_scheduled('podlove_calc_download_sums')) {\n                wp_unschedule_event(wp_next_scheduled('podlove_calc_download_sums'), 'podlove_calc_download_sums');\n            }\n\n            break;\n        case 119:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n            }\n\n            break;\n        case 120:\n            // \"clean slate\" analytics calculation\n\n            // first, ensure no jobs with wrong parameters are already setup\n            Model\\Job::delete_all();\n\n            // then queue all analytics jobs\n            $jobs = [\n                '\\Podlove\\Jobs\\UserAgentRefreshJob' => [],\n                '\\Podlove\\Jobs\\DownloadIntentCleanupJob' => ['delete_all' => true],\n                '\\Podlove\\Jobs\\DownloadTimedAggregatorJob' => ['force' => true],\n            ];\n\n            foreach ($jobs as $job => $args) {\n                CronJobRunner::create_job($job, $args);\n            }\n\n            break;\n        case 121:\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n            break;\n        case 122:\n            Cache\\TemplateCache::get_instance()->delete_cache_for('podlove_downloads_last_month');\n\n            break;\n        case 123:\n            Cache\\TemplateCache::get_instance()->purge();\n            Model\\Image::flush_cache();\n\n            break;\n        case 124:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 125:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `name` VARCHAR(255)',\n                Model\\EpisodeAsset::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 126:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` CHANGE COLUMN `name` `identifier` VARCHAR(255)',\n                Model\\EpisodeAsset::table_name()\n            ));\n\n            break;\n        case 127:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` CHANGE COLUMN `slug` `identifier` VARCHAR(255)',\n                Modules\\Contributors\\Model\\Contributor::table_name()\n            ));\n\n            break;\n        case 132:\n            $sql1 = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `title` TEXT',\n                Model\\Episode::table_name()\n            );\n            $sql2 = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `number` INT UNSIGNED',\n                Model\\Episode::table_name()\n            );\n            $sql3 = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `type` VARCHAR(10)',\n                Model\\Episode::table_name()\n            );\n            $wpdb->query($sql1);\n            $wpdb->query($sql2);\n            $wpdb->query($sql3);\n\n            break;\n        case 133:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `mnemonic` VARCHAR(8)',\n                Modules\\Seasons\\Model\\Season::table_name()\n            ));\n\n            break;\n        case 134:\n            $file_type = ['name' => 'Podigee Transcript', 'type' => 'transcript', 'mime_type' => 'plain/text', 'extension' => 'txt'];\n\n            if (!Model\\FileType::find_one_by_name($file_type['name'])) {\n                $f = new Model\\FileType();\n                foreach ($file_type as $key => $value) {\n                    $f->{$key} = $value;\n                }\n                $f->save();\n            }\n\n            break;\n        case 135:\n            delete_option(Modules\\TitleMigration\\State::OPTION);\n            Modules\\Base::activate('title_migration');\n\n            break;\n        case 136:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 137:\n            $wpdb->query(sprintf(\n                'ALTER TABLE `%s` DROP COLUMN `mnemonic`',\n                Modules\\Seasons\\Model\\Season::table_name()\n            ));\n\n            break;\n        case 138:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 139:\n            Modules\\PodloveWebPlayer\\Podlove_Web_Player::instance()->update_module_option('use_cdn', false);\n\n            break;\n        case 140:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            break;\n        case 141:\n            $table = Model\\DownloadIntentClean::table_name();\n            $index_exists = (bool) $wpdb->get_var(\n                $wpdb->prepare(\n                    \"SHOW INDEX FROM `{$table}` WHERE Key_name = %s\",\n                    'accessed_at'\n                )\n            );\n\n            if (!$index_exists) {\n                $sql = 'CREATE INDEX accessed_at ON `%s` (accessed_at)';\n                $wpdb->query(sprintf($sql, $table));\n            }\n\n            break;\n        case 142:\n            Modules\\Affiliate\\Affiliate::instance()->was_activated();\n\n            break;\n        case 143:\n            if (Modules\\Shownotes\\Model\\Entry::table_exists()) {\n                $sql = sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `affiliate_url` TEXT',\n                    Modules\\Shownotes\\Model\\Entry::table_name()\n                );\n                $wpdb->query($sql);\n            }\n\n            break;\n        case 144:\n            if (Modules\\Shownotes\\Model\\Entry::table_exists()) {\n                $sql = sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `hidden` INT',\n                    Modules\\Shownotes\\Model\\Entry::table_name()\n                );\n                $wpdb->query($sql);\n            }\n\n            break;\n        case 145:\n            // add steady\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\RepairSocial::fix_missing_services();\n            }\n\n            break;\n        case 146:\n            // add untappd\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\RepairSocial::fix_missing_services();\n            }\n\n            break;\n        case 150:\n            if (Modules\\Shownotes\\Model\\Entry::table_exists()) {\n                $sql = sprintf(\n                    'ALTER TABLE `%s` ADD COLUMN `image` TEXT',\n                    Modules\\Shownotes\\Model\\Entry::table_name()\n                );\n                \\podlove_do_migration_query($sql);\n            }\n\n            break;\n        case 151:\n            set_transient('podlove_needs_to_flush_rewrite_rules', true);\n\n            break;\n        case 152:\n            // Avoids `Row size too large.` errors.\n            // see https://community.podlove.org/t/media-validation-doesnt-work-upon-new-episode-and-storing-data-in-draft-in-general/3211/16?u=ericteubert\n            $wpdb->query(sprintf(\n                'OPTIMIZE TABLE `%s`',\n                Model\\Episode::table_name()\n            ));\n\n            $sql1 = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `soundbite_start` VARCHAR(255)',\n                Model\\Episode::table_name()\n            );\n            $sql2 = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `soundbite_duration` VARCHAR(255)',\n                Model\\Episode::table_name()\n            );\n\n            \\podlove_do_migration_query($sql1);\n            \\podlove_do_migration_query($sql2);\n\n            break;\n        case 153:\n            // FIXME: inform user when a migration fails\n            // see https://github.com/podlove/podlove-publisher/issues/1274\n\n            // Avoids `Row size too large.` errors.\n            // see https://community.podlove.org/t/media-validation-doesnt-work-upon-new-episode-and-storing-data-in-draft-in-general/3211/16?u=ericteubert\n            $wpdb->query(sprintf(\n                'OPTIMIZE TABLE `%s`',\n                Model\\Episode::table_name()\n            ));\n\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `soundbite_title` VARCHAR(255)',\n                Model\\Episode::table_name()\n            );\n\n            \\podlove_do_migration_query($sql);\n\n            break;\n        case 154:\n            $wpdb->query(sprintf(\n                'OPTIMIZE TABLE `%s`',\n                Model\\MediaFile::table_name()\n            ));\n\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `active` TINYINT',\n                Model\\MediaFile::table_name()\n            );\n\n            if (\\podlove_do_migration_query($sql)) {\n                $sql = sprintf(\n                    'UPDATE `%s` SET `active` = CASE\n                    WHEN size IS NOT NULL THEN 1\n                    ELSE 0\n                END',\n                    Model\\MediaFile::table_name()\n                );\n\n                \\podlove_do_migration_query($sql);\n            }\n\n            break;\n        case 155:\n            \\podlove_init_capabilities();\n\n            break;\n        case 156:\n            $podcast = Model\\Podcast::get();\n            $podcast->feed_transcripts = 'generated';\n            $podcast->save();\n\n            break;\n        case 157:\n            $podcast = Model\\Podcast::get();\n\n            // update deprecated \"clean\" value to \"false\"\n            if ($podcast->explicit == 2) {\n                $podcast->explicit = 0;\n                $podcast->save();\n            }\n\n            break;\n        case 159:\n            if (Modules\\Social\\Model\\Service::table_exists()) {\n                Modules\\Social\\Social::update_existing_services();\n                Modules\\Social\\Social::build_missing_services();\n            }\n\n            $podcast = Model\\Podcast::get();\n\n            if (!$podcast->guid) {\n                $podcast->guid = \\Ramsey\\Uuid\\Uuid::uuid4();\n                $podcast->save();\n            }\n\n            break;\n        case 160:\n            // Generate GUIDs for all shows that don't have one\n            $shows = \\Podlove\\Modules\\Shows\\Model\\Show::all();\n            foreach ($shows as $show) {\n                \\Podlove\\Modules\\Shows\\Model\\Show::generate_guid($show->id);\n            }\n\n            break;\n        case 161:\n            // Add slug_frozen column to episodes table\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `slug_frozen` TINYINT DEFAULT 0',\n                Model\\Episode::table_name()\n            );\n            $wpdb->query($sql);\n\n            break;\n        case 162:\n            \\Podlove\\SlugFreeze::apply_slug_freeze_to_existing_episodes();\n\n            break;\n        case 163:\n            // Activate PLUS module by default for existing installations.\n            \\Podlove\\Modules\\Base::activate('plus');\n\n            break;\n        case 164:\n            $sql = sprintf(\n                'ALTER TABLE `%s` ADD COLUMN `optimize_content_encoded_html` TINYINT(1) DEFAULT 0 AFTER `embed_content_encoded`',\n                Model\\Feed::table_name()\n            );\n            \\podlove_do_migration_query($sql);\n\n            break;\n        case 165:\n            $file_type = ['name' => 'WebVTT Captions', 'type' => 'transcript', 'mime_type' => 'text/vtt', 'extension' => 'vtt'];\n\n            if (!Model\\FileType::find_one_by_where(\"`type` = 'transcript' AND `mime_type` = 'text/vtt'\")) {\n                $f = new Model\\FileType();\n                foreach ($file_type as $key => $value) {\n                    $f->{$key} = $value;\n                }\n                $f->save();\n            }\n\n            break;\n    }\n}\n"
  },
  {
    "path": "lib/webhook/webhook.php",
    "content": "<?php\n\nnamespace Podlove\\Webhook;\n\nuse Podlove\\Http\\Curl;\n\nclass Webhook\n{\n    private $event;\n    private $payload;\n    private $method = 'POST';\n\n    public function __construct($event)\n    {\n        $this->event = $event;\n    }\n\n    public function send($url)\n    {\n        $curl = new Curl();\n        $curl->request($url, [\n            'method' => $this->method,\n            'body' => [\n                'event' => $this->event,\n                'payload' => wp_json_encode($this->payload)\n            ],\n            '_redirection' => 0\n        ]);\n    }\n\n    public function getPayload()\n    {\n        return $this->payload;\n    }\n\n    public function getMethod()\n    {\n        return $this->method;\n    }\n\n    public function payload($payload)\n    {\n        $this->payload = $payload;\n\n        return $this;\n    }\n\n    public function method($method)\n    {\n        $this->method = $method;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "license.txt",
    "content": "Copyright (C) 2012 Eric Teubert\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "mise.toml",
    "content": "[tools]\nnode = \"25\"\nphp = \"8.4\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"podlove-publisher\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"@wordpress/env\": \"^11.2.0\"\n  },\n  \"scripts\": {\n    \"wp-env\": \"wp-env\",\n    \"wp-env:start\": \"wp-env start\",\n    \"wp-env:stop\": \"wp-env stop\",\n    \"wp-env:logs\": \"npx wp-env run cli -- tail -f /var/www/html/wp-content/debug.log\",\n    \"wp-env:test:start\": \"wp-env start --config=.wp-env.test.json\",\n    \"wp-env:test:stop\": \"wp-env stop --config=.wp-env.test.json\",\n    \"wp-env:test:logs\": \"npx wp-env --config=.wp-env.test.json run cli -- tail -f /var/www/html/wp-content/debug.log\",\n    \"wp-env:test:destroy\": \"npx wp-env destroy --config=.wp-env.test.json\",\n    \"test\": \"wp-env --config=.wp-env.test.json run cli --env-cwd=wp-content/plugins/podlove-podcasting-plugin-for-wordpress vendor/bin/phpunit --configuration=phpunit.xml.dist\"\n  }\n}\n"
  },
  {
    "path": "phpunit.xml.dist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit\n    bootstrap=\"tests/phpunit/bootstrap.php\"\n    colors=\"true\"\n    beStrictAboutTestsThatDoNotTestAnything=\"true\"\n>\n    <testsuites>\n        <testsuite name=\"Podlove Publisher\">\n            <directory>tests/phpunit/integration</directory>\n            <directory>tests/phpunit/rest</directory>\n        </testsuite>\n    </testsuites>\n    <php>\n        <ini name=\"display_errors\" value=\"1\"/>\n        <ini name=\"error_reporting\" value=\"-1\"/>\n    </php>\n</phpunit>\n"
  },
  {
    "path": "plugin.php",
    "content": "<?php\n\nnamespace Podlove;\n\nregister_activation_hook(PLUGIN_FILE, __NAMESPACE__.'\\activate');\nregister_deactivation_hook(PLUGIN_FILE, __NAMESPACE__.'\\deactivate');\nregister_uninstall_hook(PLUGIN_FILE, __NAMESPACE__.'\\uninstall');\nadd_action('wpmu_new_blog', '\\Podlove\\create_new_blog', 10, 6);\nadd_action('delete_blog', '\\Podlove\\delete_blog', 10, 2);\n\nfunction activate_for_current_blog()\n{\n    \\podlove_setup_database_tables();\n    \\podlove_init_capabilities();\n    \\podlove_setup_file_types();\n    \\podlove_setup_podcast();\n    \\podlove_setup_modules();\n    \\podlove_setup_expert_settings();\n    \\podlove_setup_default_template();\n    \\podlove_setup_default_media();\n    \\podlove_setup_default_asset_assignments();\n}\n\n/**\n * Hook: Create a new blog in a multisite environment.\n *\n * When a new blog is created, we have to trigger the activation function\n * for in the scope of that blog.\n *\n * @param mixed $blog_id\n * @param mixed $user_id\n * @param mixed $domain\n * @param mixed $path\n * @param mixed $site_id\n * @param mixed $meta\n */\nfunction create_new_blog($blog_id, $user_id, $domain, $path, $site_id, $meta)\n{\n    switch_to_blog($blog_id);\n\n    $plugin_base_path = implode(DIRECTORY_SEPARATOR, array_slice(explode(DIRECTORY_SEPARATOR, PLUGIN_FILE), -2));\n\n    if (is_plugin_active_for_network($plugin_base_path)) {\n        activate_for_current_blog();\n    }\n\n    restore_current_blog();\n}\n\n/**\n * Fires before a blog is deleted.\n *\n * @param int  $blog_id the blog ID\n * @param bool $drop    true if blog's table should be dropped\n */\nfunction delete_blog($blog_id, $drop)\n{\n    if ($drop) {\n        uninstall_for_current_blog();\n    }\n}\n\n/**\n * Hook: Activate the plugin.\n *\n * In a single blog install, just call activate_for_current_blog().\n * However, in a multisite install, iterate over all blogs and call the activate\n * function for each of them.\n *\n * @param mixed $network_wide\n */\nfunction activate($network_wide)\n{\n    global $wpdb;\n\n    if ($network_wide) {\n        set_time_limit(0); // may take a while, depending on network size\n        $blogids = $wpdb->get_col('SELECT blog_id FROM '.$wpdb->blogs);\n        foreach ($blogids as $blog_id) {\n            \\Podlove\\with_blog_scope($blog_id, function () {\n                activate_for_current_blog();\n            });\n        }\n    } else {\n        activate_for_current_blog();\n    }\n\n    set_transient('podlove_needs_to_flush_rewrite_rules', true);\n    podlove_run_system_report();\n}\n\nfunction deactivate()\n{\n    flush_rewrite_rules();\n}\n\n/**\n * Hook: Uninstall the plugin.\n *\n * In a single blog install, just call uninstall_for_current_blog().\n * However, in a multisite install, iterate over all blogs and call the\n * uninstall function for each of them.\n */\nfunction uninstall()\n{\n    global $wpdb;\n\n    if (is_multisite()) {\n        if (isset($_GET['networkwide']) && ($_GET['networkwide'] == 1)) {\n            $current_blog = $wpdb->blogid;\n            $blogids = $wpdb->get_col('SELECT blog_id FROM '.$wpdb->blogs);\n            foreach ($blogids as $blog_id) {\n                switch_to_blog($blog_id);\n                uninstall_for_current_blog();\n            }\n            switch_to_blog($current_blog);\n        } else {\n            uninstall_for_current_blog();\n        }\n    } else {\n        uninstall_for_current_blog();\n    }\n}\n\nfunction uninstall_modules_for_current_blog()\n{\n    $modules = \\Podlove\\Modules\\Base::get_all_module_names();\n\n    foreach ($modules as $module_name) {\n        $class = \\Podlove\\Modules\\Base::get_class_by_module_name($module_name);\n\n        if (class_exists($class)) {\n            $class::instance()->uninstall();\n        }\n    }\n}\n\nfunction uninstall_for_current_blog()\n{\n    global $wpdb;\n\n    \\Podlove\\Cache\\TemplateCache::get_instance()->purge();\n    uninstall_modules_for_current_blog();\n\n    Model\\Feed::destroy();\n    Model\\FileType::destroy();\n    Model\\EpisodeAsset::destroy();\n    Model\\MediaFile::destroy();\n    Model\\Episode::destroy();\n    Model\\Template::destroy();\n    Model\\DownloadIntent::destroy();\n    Model\\DownloadIntentClean::destroy();\n    Model\\UserAgent::destroy();\n    Model\\GeoArea::destroy();\n    Model\\GeoAreaName::destroy();\n    Model\\Job::destroy();\n\n    // Legacy hook for external integrations that still rely on it.\n    do_action('podlove_uninstall_plugin');\n\n    // trash all episodes\n    $query = new \\WP_Query(['post_type' => 'podcast']);\n\n    if ($query->have_posts()) {\n        while ($query->have_posts()) {\n            $query->the_post();\n            wp_trash_post(get_the_ID());\n        }\n    }\n\n    wp_reset_postdata();\n\n    // clean up options\n    $options = [\n        'podlove_feed%',\n        '%podlove_chapters_string_%',\n        'podlove_podcast',\n        'podlove_active_modules',\n        'podlove_template_assignment',\n        'podlove_webplayer_formats',\n        'podlove_asset_assignment',\n        'podlove_global_messages',\n        'podlove_database_version',\n        'podlove_repair_log',\n        'podlove_import_file',\n        'podlove_import_tracking_file',\n        '_podlove_added_bitlove_to_feed_model',\n        'podlove_contributors',\n        'podlove_flattr',\n        'podlove_geo_tracking',\n        'podlove_metadata',\n        'podlove_module%',\n        'podlove_redirects',\n        'podlove_title_migration_state',\n        'podlove_tracking',\n        'podlove_website',\n        'podlove_analytics_tiles',\n    ];\n\n    foreach ($options as $option) {\n        $wpdb->query('DELETE FROM '.$wpdb->options.' WHERE option_name LIKE \"'.$option.'\"');\n    }\n}\n\n// Activate internal modules.\nadd_action('init', ['\\Podlove\\Custom_Guid', 'init']);\nadd_action('init', ['\\Podlove\\Downloads', 'init']);\nadd_action('init', ['\\Podlove\\Geo_Ip', 'init']);\nadd_action('init', ['\\Podlove\\DuplicatePost', 'init']);\nadd_action('init', ['\\Podlove\\Analytics\\EpisodeDownloadAverage', 'init']);\nadd_action('init', ['\\Podlove\\Analytics\\DownloadIntentCleanup', 'init']);\nadd_action('init', ['\\Podlove\\Analytics\\DownloadSumsCalculator', 'init']);\nadd_action('init', ['\\Podlove\\Analytics\\SaltShaker', 'init']);\n\nadd_action('admin_init', ['\\Podlove\\Repair', 'init']);\nadd_action('admin_init', ['\\Podlove\\PhpDeprecationWarning', 'init']);\n\n// init cache (after plugins_loaded, so modules have a chance to hook)\nadd_action('init', ['\\Podlove\\Cache\\TemplateCache', 'get_instance']);\n\n// require_once \\Podlove\\PLUGIN_DIR . 'includes/about.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/api/api.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/auto_post_titles.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/cache.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/capabilities.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/chapters.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/compatibility.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/db_migration.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/deprecations.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/detect_duplicate_slugs.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/donation_banner.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/downloads.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/episode_number_column.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/episode_number_quick_edit_form.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/extras.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/feed_discovery.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/frontend_styles.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/http.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/images.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/import.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/jetpack.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/license.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/merge_episodes.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/modules.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/no_enclosure_autodiscovery.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/permalinks.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/podlove_data_js_adapter.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/podlove-web-player-5.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/recording_date.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/redirects.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/request_id_rehash.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/require_curl.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/setup.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/setup_wizard.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/screen_options.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/scripts_and_styles.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/search.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/system_report.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/templates.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/template_pages.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/theme_helper.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/trash.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/verify_itunes_category.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/webhooks.php';\nrequire_once \\Podlove\\PLUGIN_DIR.'includes/wp_rocket.php';\n\nrequire_once \\Podlove\\PLUGIN_DIR.'lib/tools.php';\n\n// @todo: change to internal module\nnew \\Podlove\\AJAX\\Ajax();\nnew \\Podlove\\Settings\\Tools\\UserAgentRefresh();\n\n\\Podlove\\Jobs\\CronJobRunner::init();\n\\Podlove\\Jobs\\ToolsSection::init();\n\\Podlove\\Jobs\\JobCleaner::init();\n"
  },
  {
    "path": "podlove.php",
    "content": "<?php\n/**\n * Plugin Name: Podlove Podcast Publisher\n * Plugin URI:  https://podlove.org/podlove-podcast-publisher/\n * Version: 4.4.2\n * Requires at least: 4.9.6\n * Requires PHP: 8.0\n * Author:      Podlove\n * Author URI:  http://podlove.org\n * License:     MIT\n * License URI: license.txt\n * Text Domain: podlove-podcasting-plugin-for-wordpress\n * Description: The one and only next generation podcast publishing system. Seriously. It's magical and sparkles a lot.\n */\nfunction load_podlove_podcast_publisher()\n{\n    require_once __DIR__.'/vendor/autoload.php'; // composer autoloader\n    require_once __DIR__.'/bootstrap/bootstrap.php';\n    require_once __DIR__.'/lib/helper.php';\n    require_once __DIR__.'/lib/cron.php';\n    require_once __DIR__.'/lib/network.php';\n    require_once __DIR__.'/lib/php/array.php';\n    require_once __DIR__.'/lib/php/string.php';\n    require_once __DIR__.'/lib/version.php';\n    require_once __DIR__.'/lib/feeds.php';\n    require_once __DIR__.'/lib/shortcodes.php';\n    require_once __DIR__.'/lib/slug_freeze.php';\n    require_once __DIR__.'/plugin.php';\n}\n\nfunction podlove_admin_error_no_autoload()\n{\n    ?>\n\t<div id=\"message\" class=\"error\">\n\t\t<p>\n\t\t\t<strong>Podlove Podcast Publisher could not be activated</strong>\n\t\t</p>\n\t\t<p>\n\t\t\tPlugin files are incomplete. Please download a fresh copy of the plugin: <a href=\"https://downloads.wordpress.org/plugin/podlove-podcasting-plugin-for-wordpress.zip\">downloads.wordpress.org/plugin/podlove-podcasting-plugin-for-wordpress.zip</a> and <a href=\"https://codex.wordpress.org/Managing_Plugins#Installing_Plugins\">repeat the installation</a>.\n\t\t</p>\n\t</div>\n\t<?php\n}\n\nfunction podlove_admin_error_ancient_php()\n{\n    ?>\n\t<div id=\"message\" class=\"error\">\n\t\t<p>\n\t\t\t<strong>Podlove Podcast Publisher could not be activated</strong>\n\t\t</p>\n\t\t<p>\n\t\t\tPodlove Podcasting Plugin requires <code>PHP 8.0</code> or higher.<br>\n\t\t\tYou are running <code>PHP <?php echo phpversion(); ?></code>.<br>\n\t\t\tPlease ask your hoster how to upgrade to an up-to-date PHP version.\n\t\t</p>\n\t\t<p>\n\t\t\tIf you need to go back to an older Publisher version,\n\t\t\tyou can find a list of all available downloads at\n\t\t\t<a href=\"https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/developers/\">wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/developers/</a>.\n\t\t</p>\n\t</div>\n\t<?php\n}\n\nfunction podlove_deactivate_plugin()\n{\n    add_action('admin_init', function () {\n        deactivate_plugins(plugin_basename(__FILE__));\n    });\n}\n\n$correct_php_version = version_compare(phpversion(), '8.0', '>=');\n\nif (!$correct_php_version) {\n    // Let the plugin update/setup succeed and constantly show the error\n    // message until resolved.\n    add_action('admin_notices', 'podlove_admin_error_ancient_php');\n    podlove_deactivate_plugin();\n} elseif (!file_exists(trailingslashit(dirname(__FILE__)).'vendor/autoload.php')) {\n    // Looks like this can happen on cheap shared hosting. Update fails and leaves\n    // the Publisher in an unusable state. From experience it's always at least\n    // 'vendor/autoload.php' that is missing. This also catches users that accidentally\n    // download the development version from GitHub.\n    add_action('admin_notices', 'podlove_admin_error_no_autoload');\n    podlove_deactivate_plugin();\n} else {\n    load_podlove_podcast_publisher();\n}\n"
  },
  {
    "path": "readme.txt",
    "content": "=== Podlove Podcast Publisher ===\nContributors: eteubert\nDonate link: https://opencollective.com/podlove\nTags: podlove, podcast, publishing, rss, audio\nTested up to: 6.9.4\nStable tag: 4.5.0\nRequires at least: 4.9.6\nRequires PHP: 8.0\nLicense: MIT\n\nThe one and only next generation podcast publishing system. Seriously. It's magical and sparkles a lot.\n\n== Description ==\n\nWe started the Podlove Podcast Publisher project in 2012 because existing solutions were stuck in the past, complex and unwieldy. The Publisher helps you save time, worry less and provides a cutting edge listening experience for your audience.\n\nOfficial Site: [podlove.org/podlove-podcast-publisher](https://podlove.org/podlove-podcast-publisher)\n\n### Getting Started Videos\n\nStarting fresh with Podlove Publisher:\n\nhttps://www.youtube.com/watch?v=2UZrmPAcyrs\n\nMigrating an existing podcast to Podlove Publisher:\n\nhttps://www.youtube.com/watch?v=s6jL6jk6hWk\n\n### Compatible RSS Feeds\n\nThe Publisher makes it easy to create highly expressive, efficient and super compatible podcast feeds with fine grained control over client behavior (e.g. GUID control to replace faulty episodes and for clients to reload) supporting all important meta data.\n\n### Multi-Format Publishing\n\nThe Publisher also makes multi-format publishing - embracing all modern and legacy audio and video codecs - a snap. By adopting simple file name conventions, the plugin allows the podcaster to provide individual feeds for certain use cases or audiences without adding work for the podcaster during the publishing process.\n\n### Optimized Web Player\n\nThe Publisher also comes integrated with the Podlove Web Player plugin and fully supports its advanced options including multiple audio (MP4 AAC, MP3, Vorbis, Opus) and video (MP4 H.264, WebM, Theora) format support for web browsers. This Web Player is fully HTML5 compatible and is ready for all touch based clients too.\n\n\n### Metadata Galore\n\n* **Chapter Marks:** The Publisher also makes it easy to publish chapter information in the player to make access to structured episodes even easier. Full support for linking directly to any part of your podcast on the web with instant playback included.\n* **Contributors:** Bring your team and guests front and center. Manage contributors, including their names, avatars and web urls.\n* **Transcripts:** WebVTT transcripts can be imported and even connected to your contributors. They are referenced in the RSS feed so they can be displayed by podcast apps.\n* **Seasons:** Does your podcast have seasons? We got you covered with a dedicated \"Seasons\" module.\n* **Related Episodes:** Manage and display related episodes on your website.\n\n### Auphonic Integration\n\nAuphonic is your all-in-one audio post production webtool to achieve a professional quality result. We provide a first class integration module for ease of use and best automation experience.\n\n### Flexible Templates\n\nTo round it all up, a flexible template system enables you to published Podcasts in a defined fashion and change the style at any time without having to touch your individual postings later on.\n\nAnd this is just the beginning. We have a rich roadmap that will bring even more interesting features: integration with helpful services, much improved timeline metadata support (show notes) and much more.\n\n= Further Reading =\n\n* [Podlove Publisher](http://publisher.podlove.org/)\n* [Podlove Project](http://podlove.org/)\n* [Podlove Community](https://community.podlove.org/)\n* [Documentation](http://docs.podlove.org/)\n* [Bug Tracker](https://github.com/podlove/podlove-publisher/issues)\n* [Donate](http://podlove.org/donations/)\n\nDevelopment of the plugin is an open process. The current version is available [on GitHub](https://github.com/podlove/podlove-publisher) Feel free to contribute and to fix errors or send improvements via GitHub.\n\nRequires PHP 8.0+\n\n== Frequently Asked Questions ==\n\n### Is Podlove Podcast Publisher free?\n\nYes! The core features of Podlove Podcast Publisher are and always will be free.\n\n### Are there Download Statistics?\n\nYes! Podcast Downloads can be tracked and analyzed. You can easily see how many people downloaded you podcast episodes, which clients they used, if they prefer to subscribe to the feed or listen on your website using the web player—and much more.\n\n### Are there Privacy / GDPR considerations?\n\nPodlove Publisher is GDPR compliant and provides prewritten text snippets for your privacy page. See https://docs.podlove.org/podlove-publisher/guides/dsgvo-gdpr.html\n\n### Where can I host my podcast files?\n\nAny storage where you have control over the file naming is compatible with Podlove Podcast Publisher. You can manage files using a simple FTP/sFTP or use services like Amazon S3.\n\n### Where can I ask questions and get support?\n\nFree support where questions are answered by the community is available in the [Podlove Community Forum](http://community.podlove.org/). There is a German community in the [Sendegate](https://sendegate.de/).\n\n### How can I help the project?\n\nThe continued success of Open Source project relies on the community. There are many ways you can help:\nEpisode title in API now follows the same rules as in RSS feed. There's a new field 'title_clean' for accessing the specifically set plain episode title, but that might be null in some cases, so it's better to default the 'title' attribute to the usual rules.\n- If you enjoy the plugin, please [leave a review](https://wordpress.org/support/plugin/podlove-podcasting-plugin-for-wordpress/reviews/#new-post).\n- You can answer questions of other fellow podcasters in the [Podlove Community](https://community.podlove.org/).\n\n### Where do I report security bugs found in this plugin? =\n\nPlease report security bugs found in the source code of the Podlove Podcast Publisher plugin through the [Patchstack Vulnerability Disclosure  Program](https://patchstack.com/database/vdp/9e5fb42f-70ee-4afb-9e86-886900031833). The Patchstack team will assist you with verification, CVE assignment, and notify the developers of this plugin.\n\n---\n\nThis product includes GeoLite2 data created by MaxMind, available from http://www.maxmind.com.\n\n== Installation ==\n\n1. Download the Podlove Publisher Plugin to your desktop.\n1. If downloaded as a zip archive, extract the Plugin folder to your desktop.\n1. With your FTP program, upload the Plugin folder to the wp-content/plugins folder in your WordPress directory online.\n1. Go to Plugins screen and find the newly uploaded Plugin in the list.\n1. Click Activate Plugin to activate it.\n\n== Screenshots ==\n1. Custom episode post type separates media from your blog content.\n2. Download analytics provide you with all the data you ever wanted.\n3. The Publisher automatically checks the health of your media files.\n4. The mighty template engine gives you full control over the episode presentation.\n5. Includes the Podlove Subscribe Button, the easiest way for listeners to subscribe to your podcast.\n6. Includes the Podlove Web Player. One more thing: you can manage and present all contributors easily.\n\n== Changelog ==\n\n= 4.5.0\n\n* new: \"Optimize HTML Content\" in feed settings strips non-essential attributes from episode shownotes in the RSS feed\n* new: show podcast guid in podcast settings\n* fix: transcription format \"VTT\" can now be found under \"transcript\" type, not just \"caption\"\n\nNote: This release includes a database migration that adds a table column.\n\n= 4.4.3 =\n\n* improve: episode contributor selection interface now uses whichever name is available, same logic as everywhere else (prefer public name, then real name, then nickname)\n* fix: uninstalling the plugin removes all database tables\n\n= 4.4.2 =\n\n* fix: Related Episodes selector shows labels again (regression from 4.4.0)\n\n= 4.4.1 =\n\n* fix: Auphonic preset selector shows preset label again (regression from 4.4.0)\n\n= 4.4.0 =\n\n**Tracking & Analytics**\n\n* new: add download tracking support for Matomo (see Expert Settings > Tracking)\n* new: \"Recent Downloads\" chart can now be added to the WordPress dashboard\n* improve: reorganize tracking settings page\n\n**Auphonic**\n\n* new: Auphonic module now supports providing an API key in addition to the OAuth flow\n* new: Auphonic module now supports chapter images in the production\n* new: when re-running a production in Auphonic, Publisher now detects new files and presents a \"Re-upload to PLUS\" button\n* fix: switch Auphonic OAuth to the new `auth.podlove.org` service (OAuth in older Publisher versions will no longer work)\n\n**Platform & Integrations**\n\n* new: Codeberg support as contributor social service\n* new: repair tool detects missing permission to alter tables, which prevents some database migrations from running successfully\n* new: add some situational notices that PLUS exists\n\n**Maintenance**\n\n* update various JavaScript dependencies\n\n= 4.3.5 =\n\n* new: the \"Repair\" function in \"Tools\" now checks for missing database columns and adds missing ones\n\n= 4.3.4 =\n\n* change: enable the Publisher PLUS module by default for new and existing installs\n* fix: prevent connection to PLUS service when module is deactivated\n* fix: prevent feed proxy when PLUS module is deactivated\n* fix: escape missing template id error in podlove-template shortcode\n* fix: various PHP deprecation warnings and notices\n\n= 4.3.3 =\n\n* fix: allow selecting chapter images from the media library\n* fix: prevent media files module from hanging on new episodes without an ID\n* change: default new contributors to visible\n* fix: treat inactive episode files as nonexistent in templates\n\n= 4.3.2 =\n\n* improve permission verification when deleting default and podcast contributions\n\n= 4.3.1 =\n\n* add \"podcast:chapters\" tag to RSS feed, which Apple Podcast now prefers for chapters information.\n* change default database table character set to utf8mb4 for emoji support. For safety reasons, there is no automatic migration provided. If you prefer, you can update Publisher tables manually via SQL, but in most cases the episode table will be all you need: `ALTER TABLE wp_podlove_episode CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`\n\n= 4.3.0 =\n\nReworked and extended the **Publisher PLUS** service. It now offers the \"File Storage\" feature to conveniently upload media files to PLUS instead of storing it in WordPress or other external locations.\n\nTo get started, navigate to \"Podlove - Modules\", find \"Publisher PLUS\" and activate it. This will add a new menu entry with further instructions.\n\nThere is a migration tool to copy existing media files to PLUS.\n\nGive it a try if this kind of service interests you. And please do give feedback :)\n\n**Other Changes**\n\n- PLUS feature management moved: there is now a \"Publisher PLUS\" settings page for token management and feature toggles\n- new: media file slug \"freeze\". As soon as the first media file validates, the\nslug is marked as \"frozen\" so it cannot be edited accidently any more, by\nautomations or manual interaction. There is an edit button to consciously change\nit, if necessary\n- fixes \"Activate for all existing Episodes\" button in assets\n\n= 4.2.7 =\n\n- security: Improved handling of image files in the download cache to block malicious uploads.\n\nNote: this should not affect normal image delivery, but please keep an eye out for any unexpected issues with cached images.\n\n= 4.2.6 =\n\n- fix: open redirection vulnerability\n\n= 4.2.5 =\n\n- fix: an episode that is assigned to a show can now be taken out of that show\n- load more Auphonic productions to select from (50)\n\n= 4.2.4 =\n\n- fix: when upgrading YOAST SEO while the Publisher is active, permalinks do not break any more\n- onboarding: show warning if application passwords are disabled\n- include feeds in `GET podlove/v2/shows` result\n\n= 4.2.3 =\n\n- feat: add API route to list public feeds: `GET podlove/v2/feeds`\n- security: remove unused code (which contained an XSS vulnerability)\n\n= 4.2.2 =\n\nFix GUIDs for shows:\n\n- generate a guid for all existing shows\n- use the guid in the RSS feed, instead of the podcast guid\n- new shows will get a new guid\n\n= 4.2.1 =\n\n- security: XSS vulnerability in podcast summary\n\n= 4.2.0 =\n\nThis release introduces the all-new **Onboarding Assistant**, enabling you to\neither setup a new podcast or move your existing podcast to Podlove with just\nyour RSS Feed URL.\n\nThis makes it much easier for potential podcasters to get started, as it makes\nthe initial setup process as simple and quick as possible, now only requiring\nthe podcaster to define what's really necessary about the podcast, without\nneeding to worry about technical details.\n\nAnd for the first time, you can now move any of your existing podcasts to\nPodlove Publisher. All you need is the RSS feed. Podlove Publisher finds all the\nepisode data and metadata like the audio file, title, description, chapters,\ntranscript and contributors and imports them automatically.\n\nA huge Thank You goes to the [Prototype Fund](https://prototypefund.de/project/podlove-publisher-onboarding-import-assistant/)\nfor sponsoring the development of the Onboarding Assistant module.\n\n**Other Changes**\n\n- removes outdated \"Migration Wizard\"\n- generate a guid for the podcast and use it in the RSS feed `podcast:guid` tag\n- API changes:\n  - episodes: allow filtering by `guid`\n  - podcast: include the following fields in responses: guid, language, feeds\n- fix: respect slashes in file slugs when urlencoding\n- security: XSS vulnerability in feed name\n\n= 4.1.25 =\n\n* fix: Auphonic \"Start Production\" is working again\n\n= 4.1.24 =\n\n* security: XSS vulnerability in episode assets title\n* feat: add API route to clear caches: `DELETE podlove/v2/tools/clear-caches`\n* feat: add bluesky to social services\n\n= 4.1.23 =\n\n* new: Auphonic file uploads display their upload progress\n* fix: image cache sometimes saved the resized files under a wrong filename\n* fix: image cache is now more robust, redirecting to the original image url instead of failing when any processing step fails\n* fix: oembed xml generation with escaping\n* fix: various PHP warnings/notices when trying to access nonexistent array keys\n\n= 4.1.22 =\n\n* fix: empty vtt transcripts (when no voices were assigned to contributors)\n* fix: error when unsetting a transcript voice assignment\n* fix: PHP warning while using a deprecated podcast category\n\n= 4.1.21 =\n\n* fix: encode tracking urls and their redirected urls\n\n= 4.1.20 =\n\n* fix: when using the \"Post Thumbnail\" setting for episode images, the chosen\nimage is now immediately shown in the \"Episode Description\" box and it is sent\nto Auphonic when saving a production.\n* fix: Auphonic status polling now only gets called when appropriate, instead of on every page load\n* fix: improve WordPress 6.7 compatibility (related to translation api)\n\n= 4.1.19 =\n\n* fix some templates by allowing `service.name` (but it's deprecated, so you should change your templates to use `service.title` instead)\n\n= 4.1.18 =\n\n* fix `episode.transcript` accessor\n\n= 4.1.17 =\n\n* fix some templates that broke with 4.1.16\n\n= 4.1.16 =\n\n* security: use Twig in Sandbox mode\n* change: reenable recently disabled Twig Template filters \"filter\", \"map\" and \"reduce\"\n* fix: plugin crash on setup when default WordPress roles do not exist\n\n= 4.1.15 =\n\n* security: disable unsafe Twig Template filters \"filter\", \"map\" and \"reduce\"\n\n= 4.1.14 =\n\n* security: add nonces to template actions\n* security: use output escaping for episode slug\n\n= 4.1.13 =\n\n* add transcripts to import/export\n* add confirmation button on modal to input episode image url\n* fix: selecting an episode image updates the preview\n\n= 4.1.12 =\n\n* new: when selecting a file for Auphonic, its name is suggested to be used as episode slug\n* fix: PHP deprecation warnings\n\n= 4.1.11 =\n\n* new: support `podcast:license` tag in RSS feed\n* fix: imports correct chapter times from Auphonic (when using features that affect the episode duration)\n* maintenance: update various javascript dependencies\n\n= 4.1.10 =\n\n* fix: bug introduced in latest patch that created duplicate database entries for media files\n\n= 4.1.9 =\n\nVarious fixes regarding adjusted media file handling introduced in version 4.0.11:\n\n- dashboard shows a grey bar again when the file is inactive\n- RSS feed correctly skips items with inactive files again\n\n= 4.1.8 =\n\n* changes in an episode immediately affect the RSS feed\n\nThere are various caches in place to ensure efficient delivery of the RSS feed\nto podcast clients. However it can be hard to guess how long it will take for a\nchange to appear in the feed and podcatchers. Now, any change to the episode\nmetadata or enabling/disabling an asset immediately clears the cache for that\nfeed item, resulting in the change to be visible in the RSS feed immediately.\n\n* transcripts: contributors in voices selection are sorted alphabetically\n* fix: episode license and explicit value are not emptied when saving the episode\n* maintenance: fix various notices from WordPress Plugin Check tool\n* maintenance: js dependency updates\n\n\n= 4.1.7 =\n\n* fix `itunes:explicit` RSS tag. It now contains the valid values \"true\" or \"false\".\n* fix typo in API: `explicit` field was mistyped as `expicit`\n\n= 4.1.6 =\n\n* Shows Settings: sort Auphonic presets alphabetically\n\n= 4.1.5 =\n\n* Hotfix: Creating Auphonic productions works again\n\n= 4.1.4 =\n\n* Auphonic: reduce preset cache to 10 seconds so manual refreshing is not necessary (and remove broken refresh button on module page)\n* Auphonic: select configured show preset when selecting a show\n* Auphonic: fix status updates when opening an episode with an already running production\n* performance: don't try to fetch file headers twice for unreachable URLs\n* performance: faster saving of slug changes\n\n= 4.1.3 =\n\n* Shows selection in the episode was rewritten for the new frontend stack. It is now compatible with the Automatic Numbering module again.\n\n= 4.1.2 =\n\n* transcripts: include json link in RSS again when \"Publisher Generated\" is selected\n* transcripts: polish timestamp rendering in preview (shorter, monospaced, right aligned, unselectable)\n* chapters: fix \"unknown\" durations\n\n= 4.1.1 =\n\n* security: add nonces to tools actions\n\n= 4.1.0 =\n\n**Feature: Better control over transcripts in RSS feed**\n\nThere is a new feature under `Podlove > Podcast Feeds` called \"Episode\nTranscripts\" to control how episode transcripts should be referenced in your RSS\nfeed. The default is \"Publisher Generated vtt\", which is nearly the same\nbehavior as before (In previous versions, three variants were referenced: vtt,\njson and srt. The json and srt URLs still work but they are not referenced in\nthe RSS feed any more as they are not neccessary and removing them reduces feed\nsize). There is a dedicated \"Do not include in feed\" option, in case you do not\nwant the transcripts in your RSS feed.\n\nFinally, you may prefer to host your transcript assets externally, just like\nyour audio files. If you have configured a transcript asset, you can now select\nit in this setting. Then the RSS feed will reference this external file\ndirectly.\n\n**Auphonic improvements**\n\n- add button to always access the import screen\n- add button to delete a track\n- show status in production selection\n- rearrange / polish various some button and information positions\n\n**Other**\n\n- add: capability \"podlove_manage_contributors\" for contributors settings screens\n- fix: sometimes missing voices in Podlove Web Player transcripts\n- fix: sometimes an enabled asset is disabled a few moments later\n- fix: show files as \"not found\" when they become unreachable\n- transcripts: rename \"delete\" action to \"clear\"\n- transcripts: show timestamps in editor preview\n- upgrade heroicons (icon library) from v1 to v2\n\n----\n\nChanges for previous versions can be found in the [`changelog.txt`](https://github.com/podlove/podlove-publisher/blob/master/changelog.txt).\n\n== Upgrade Notice ==\n\n= 4.0.0 =\n\nAn all-new episode creation experience. Requires PHP 8.0 and above.\n"
  },
  {
    "path": "templates/feed-rss2.php",
    "content": "<?php\n/**\n * RSS2 Feed Template for displaying RSS2 Posts feed.\n */\nheader('Content-Type: application/rss+xml; charset='.get_option('blog_charset'), true);\n$more = 1;\n\necho '<?xml version=\"1.0\" encoding=\"'.get_option('blog_charset').'\"?>'; ?>\n<rss version=\"2.0\"\n\txmlns:atom=\"http://www.w3.org/2005/Atom\"\n\t<?php do_action('rss2_ns'); ?>\n>\n\n<channel>\n\t<title><?php echo apply_filters('podlove_feed_title', ''); ?></title>\n\t<link><?php echo apply_filters('podlove_feed_link', \\Podlove\\get_landing_page_url()); ?></link>\n\t<description><?php echo apply_filters('podlove_rss_feed_description', get_bloginfo_rss('description')); ?></description>\n\t<lastBuildDate><?php echo mysql2date('D, d M Y H:i:s +0000', get_lastpostmodified('GMT'), false); ?></lastBuildDate>\n\t<?php do_action('rss2_head'); ?>\n\n\t<?php while (have_posts()) {\n\t    the_post(); ?>\n\n\t<item>\n        <?php echo \\Podlove\\Feeds\\get_xml_text_node('title', \\Podlove\\Feeds\\get_episode_title()).\"\\n\"; ?>\n\t\t<link><?php the_permalink(); ?></link>\n\t\t<pubDate><?php echo mysql2date('D, d M Y H:i:s +0000', get_post_time('Y-m-d H:i:s', true), false); ?></pubDate>\n\t\t<guid isPermaLink=\"false\"><?php echo htmlspecialchars(get_the_guid()); ?></guid>\n    \t<?php do_action('rss2_item'); ?>\n\t</item>\n\t<?php\n\t} ?>\n</channel>\n</rss>\n"
  },
  {
    "path": "templates/license.twig",
    "content": "{#\n\tInclude example:\n\t{% include '@core/license.twig' %}\n\n\tYou can pass in a license to determine which one is displayed:\n\t{% include '@core/license.twig' with {'license': podcast.license} %}\t\n#}\n{% if license is not defined %}\n\t{% if episode is not null and episode.license.valid %}\n\t\t{% set license = episode.license %}\n\t{% else %}\n\t    {% set license = podcast.license %}\n\t{% endif %}\n{% endif %}\n\n{% if license.valid %}\n\t{% if license.creativeCommons %}\n\t\t<div class=\"podlove_cc_license\">\n\t\t\t<img src=\"{{ license.imageUrl }}\" alt=\"License\" />\n\t\t\t<p>\n\t\t\t\tThis work is licensed under a <a rel=\"license\" href=\"{{ license.url }}\">{{ license.name }}</a>\n\t\t\t</p>\n\t\t</div>\n\t{% else %}\n\t    This work is licensed under the <a href=\"{{ license.url }}\">{{ license.name }}</a> license.\n\t{% endif %}\n{% else %}\n    <div class=\"podlove_license\">\n\t\t<p style=\"color: red;\">\n\t\t\tThis work is (not yet) licensed, as no license was chosen.\n\t\t</p>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/network/network-bar.twig",
    "content": "{% set barNetwork = id is defined ? network.lists({ id: id }) %}\n{% set allPodcastsLabel = allPodcastsLabel | default(__('All podcasts', 'podlove-podcasting-plugin-for-wordpress')) %}\n{% set linkLabel = linkLabel | default(__('More information', 'podlove-podcasting-plugin-for-wordpress')) %}\n{% set backgroundColor = backgroundColor | default('#bbbabb') %}\n{% set textColor = textColor | default('#000') %}\n\n{% if barNetwork %}\n    <div class=\"podlove-network-bar\" id=\"podlove-network-bar-{{ id }}\">\n        <button class=\"podlove-network-bar__toggle\" aria-expanded=\"false\">\n            <div class=\"podlove-network-bar__toggle-inner\">\n                {{ barNetwork.logo.html({ height: 44, class: 'podlove-network-bar__toggle-image' }) }}\n                <span class=\"podlove-network-bar__toggle-title\">{{ barNetwork.title }}</span>\n                <span class=\"podlove-network-bar__toggle-expend\">\n                    <span class=\"podlove-network-bar__toggle-expend-inner\">{{ allPodcastsLabel }}</span>\n                    <svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" viewBox=\"0 0 320 512\" class=\"podlove-network-bar__toggle-expend-icon\">\n                        <path fill=\"currentColor\" d=\"M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z\"></path>\n                    </svg>\n                </span>\n            </div>\n        </button>\n        \n        <div class=\"podlove-network-bar__bar\" tabindex=\"0\" hidden=\"hidden\">\n            <div class=\"podlove-network-bar__bar-content\">\n                {{ barNetwork.summary | wpautop }}\n                {% if barNetwork.url %}\n                    <p><a href=\"{{ barNetwork.url }}\" class=\"podlove-network-bar__bar-button\">{{ linkLabel }}</a></p>\n                {% endif %}\n            </div>\n            \n            <ul class=\"podlove-network-bar__bar-podcasts\">\n                {% for podcast in barNetwork.podcasts %}\n                    <li class=\"podlove-network-bar__bar-podcast\">\n                        <a href=\"{{ podcast.landingPageUrl }}\" title=\"{{ podcast.title }}\" class=\"podlove-network-bar__bar-podcast-link\">\n                            {{ podcast.image.html({ width: 100, class: 'podlove-network-bar__bar-podcast-cover' }) }}\n                        </a>\n                    </li>\n                {% endfor %}\n            </ul>\n        </div>\n    </div>\n    \n    <style>\n        .podlove-network-bar {\n            position: sticky;\n            top: 0;\n            background-color: {{ backgroundColor }};\n            font-size: 20px;\n            font-weight: normal;\n            font-style: normal;\n            color: {{ textColor }};\n            z-index: 99999;\n        }\n        \n        body.admin-bar .podlove-network-bar {\n            top: 32px;\n        }\n        \n    \t@media screen and ( max-width: 782px ) {\n    \t    body.admin-bar .podlove-network-bar {\n                top: 0px;\n            }\n    \t}\n        \n        .podlove-network-bar__toggle {\n            background-color: transparent;\n            border: 0;\n            padding: 0;\n            width: 100%;\n            display: block;\n            text-align: left;\n            font-weight: normal;\n            color: {{ textColor }};\n            line-height: 1;\n            font-size: 14px;\n        }\n        \n        .podlove-network-bar__toggle:hover,\n        .podlove-network-bar__toggle:focus {\n            font-weight: normal;\n            background-color: transparent;\n            color: {{ textColor }};\n        }\n        \n        .podlove-network-bar__toggle-inner {\n            display: flex;\n            height: 44px;\n            align-items: center;\n        }\n        \n        .podlove-network-bar__toggle-image {\n            height: 44px;\n            width: auto;\n            flex-shrink: 0;\n            margin-right: 10px;\n        }\n        \n        .podlove-network-bar__toggle-title {\n            flex-grow: 2;\n            font-weight: bold;\n        }\n        \n        .podlove-network-bar__toggle-expend {\n            margin-left: auto;\n            padding-right: 15px;\n            padding-left: 15px;\n            height: 44px;\n            line-height: 44px;\n        }\n        \n        .podlove-network-bar__toggle:hover .podlove-network-bar__toggle-expend,\n        .podlove-network-bar__toggle:focus .podlove-network-bar__toggle-expend {\n            background-color: {{ textColor }};\n            color: {{ backgroundColor }};\n        }\n        \n        .podlove-network-bar__toggle-expend-icon {\n            width: 1em;\n            height: 1em;\n            vertical-align: -2px;\n            fill: currentColor;\n        }\n        \n        .podlove-network-bar__bar {\n            display: block;\n            padding: 15px;\n            border-top: 1px solid rgba(0, 0, 0, 0.2);\n        }\n        \n        .podlove-network-bar__bar:focus {\n            outline: 0;\n        }\n        \n        .podlove-network-bar__bar[hidden] {\n            display: none;\n        }\n        \n        .podlove-network-bar__bar-content {\n            font-size: 14px;\n            line-height: 1.4;\n            margin-bottom: 15px;\n            margin-right: 15px;\n        }\n        \n        .podlove-network-bar__bar-content p {\n            margin-bottom: 10px;\n            font-size: 14px;\n            line-height: 1.4;\n        }\n        \n        .podlove-network-bar__bar-content p:last-child {\n            margin-bottom: 0;\n        }\n        \n        .podlove-network-bar__bar-button {\n            background-color: {{ textColor }};\n            padding: 5px 15px;\n            color: {{ backgroundColor }};\n            display: inline-block;\n            font-weight: bold;\n            text-decoration: none;\n        }\n        \n        .podlove-network-bar__bar-button:hover,\n        .podlove-network-bar__bar-button:focus {\n            text-decoration: underline;\n            color: {{ backgroundColor }};\n        }\n        \n        .podlove-network-bar__bar-podcasts {\n            list-style: none;\n            display: flex;\n            margin: -5px;\n            padding: 0;\n            flex-wrap: wrap;\n        }\n        \n        .podlove-network-bar__bar-podcast {\n            padding: 5px;\n            width: 33.33%;\n            max-width: 100px;\n        }\n        \n        .podlove-network-bar__bar-podcast-link {\n            display: block;\n            line-height: 0;\n        }\n        \n        @media screen and ( min-width: 580px ) {\n            .podlove-network-bar__bar {\n                display: flex;\n            }\n            \n            .podlove-network-bar__bar-content {\n                max-width: 300px;\n                width: 50%;\n                margin-bottom: 0px;\n            }\n        }\n        \n        @media screen and ( min-width: 768px ) {\n            .podlove-network-bar__toggle {\n                font-size: 16px;\n            }\n        }\n    </style>\n    \n    <script>\n        var $networkBar = document.querySelector('#podlove-network-bar-{{ id }}');\n        var $networkBarToggle = $networkBar.querySelector('.podlove-network-bar__toggle');\n        var $networkBarContent = $networkBar.querySelector('.podlove-network-bar__bar');\n        \n        $networkBarToggle.addEventListener('click', function (event) {\n            event.preventDefault();\n            var isOpen = $networkBarToggle.getAttribute('aria-expanded') === 'true';\n            $networkBarToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');\n            $networkBarContent.hidden = isOpen;\n            if (!isOpen) {\n                $networkBarContent.focus();\n            } else {\n                $networkBarToggle.focus();\n            }\n        });\n    </script>\n{% endif %}\n"
  },
  {
    "path": "templates/shortcode/downloads-buttons.twig",
    "content": "<ul class=\"episode_download_list\">\n\t<li>Download:</li>\n\t{% for file in episode.files %}\n\t    {% set asset = file.asset %}\n\t    {% if asset.downloadable and file.active %}\n\t\t\t<li>\n\t\t\t\t<a href=\"{{ file.publicUrl(\"download\", \"buttonlist\") }}\">{{ asset.title }}<span class=\"size\">{{ file.size|formatBytes }}</span></a>\n\t\t\t</li>\n\t    {% endif %}\n\t{% endfor %}\n</ul>\n<div style=\"clear: both\"></div>\n"
  },
  {
    "path": "templates/shortcode/downloads-select.twig",
    "content": "{% apply spaceless %}\n<form action=\"#\">\n <div class=\"episode_downloads\">\n    {% if podcast.setting(\"tracking\", \"mode\") in [\"ptm\", \"ptm_analytics\"] %}\n        <input type=\"hidden\" name=\"ptm_source\" value=\"download\" />\n        <input type=\"hidden\" name=\"ptm_context\" value=\"select-button\" />\n    {% endif %}\n    <select name=\"download_media_file\">\n    {% for file in episode.files %}\n        {% set asset = file.asset %}\n        {% if asset.downloadable and file.active %}\n            <option value=\"{{ file.id }}\" data-raw-url=\"{{ file.publicUrl(\"download\", \"select-show\") }}\">{{ asset.title }} [{{ file.size|formatBytes }}]</option>\n        {% endif %}\n    {% endfor %}\n    </select>\n    <button class=\"podlove-download-primary\">Download</button>\n    <button class=\"podlove-download-secondary\">Show URL</button>\n </div>\n</form>\n\n<script>\nfunction podlove_document_ready(fn) {\n  if (document.readyState != 'loading'){\n    fn();\n  } else {\n    document.addEventListener('DOMContentLoaded', fn);\n  }\n}\n\npodlove_document_ready(function() {\n\tdocument.querySelectorAll(\".episode_downloads\").forEach(function(item) {\n        var selectEl = item.querySelector(\"select\");\n\n        item.querySelector(\"button.podlove-download-secondary\").addEventListener(\"click\", function(e) {\n\t\t\te.preventDefault();\n            var optionEl = selectEl.querySelector(\"option[value='\"+ selectEl.value +\"']\")\n            let url = optionEl.dataset.rawUrl;\n\t\t\tprompt(\"Feel free to copy and paste this URL\", url);\n\t\t\treturn false;\n\t\t})\n\t});\n});\n</script>\n{% endapply %}\n"
  },
  {
    "path": "templates/shortcode/episode-list.twig",
    "content": "<table>\n    <thead>\n        <th></th>\n        <th>Date</th>\n        <th>Title</th>\n        <th>Duration</th>\n    </thead>\n    <tbody>\n    {% for episode in podcast.episodes %}\n        <tr class=\"podcast_archive_element\">\n            <td class=\"thumbnail\">\n                {{ episode.image({fallback: true}).html({width: 64, height: 64}) }}\n            </td>\n            <td class=\"date\">\n                <span class=\"release_date\">\n                    {{ episode.publicationDate }}\n                </span>\n            </td>\n            <td class=\"title\">\n                <a href=\"{{ episode.url }}\">\n                    <strong>{{ episode.title }}</strong><br>\n                    {{ episode.subtitle }}\n                </a>\n            </td>\n            <td class=\"duration\">\n                {% set duration = episode.duration %}\n                {{ duration.hours }}:{{ duration.minutes|padLeft(\"0\",2) }}:{{ duration.seconds|padLeft(\"0\",2) }}\n            </td>\n        </tr>\n    {% endfor %}\n    </tbody>\n</table>\n\n<style type=\"text/css\">\n.podcast_archive_element .thumbnail {\n    width: 64px;\n    padding: 5px !important;\n}\n\n.podcast_archive_element td {\n    vertical-align: top;\n}\n</style>"
  },
  {
    "path": "templates/shortcode/feed-list.twig",
    "content": "<table>\n\t<thead>\n    \t<tr>\n            <th>Feed</th>\n        \t<th>Enclosure</th>\n        </tr>\n    </thead>\n    <tbody>\n    {% for feed in podcast.feeds %}\n\t\t{% if feed.discoverable %}\n\t\t    <tr>\n            \t<td>\n                    <a href=\"{{ feed.url }}\">{{ feed.title }}</a>\n                </td>\n                <td>\n                    <span title=\"{{ feed.asset.fileType.mimeType }} ({{ feed.asset.fileType.extension }})\">\n                        {{ feed.asset.title }}\n                    </span>\n                </td>\n            </tr>\n    \t{% endif %}\n\t{% endfor %}\n    </tbody>\n</table>"
  },
  {
    "path": "tests/phpunit/bootstrap.php",
    "content": "<?php\n\n$_tests_dir = getenv('WP_TESTS_DIR');\n\nif (!$_tests_dir) {\n    $_tests_dir = '/wordpress-phpunit';\n}\n\nif (!file_exists($_tests_dir.'/includes/functions.php')) {\n    echo \"Could not find WordPress tests directory at: {$_tests_dir}\\n\";\n    exit(1);\n}\n\nif (file_exists(dirname(__DIR__, 2).'/vendor/autoload.php')) {\n    require dirname(__DIR__, 2).'/vendor/autoload.php';\n}\n\nrequire_once $_tests_dir.'/includes/functions.php';\n\ntests_add_filter('muplugins_loaded', function () {\n    require dirname(__DIR__, 2).'/podlove.php';\n});\n\nrequire_once $_tests_dir.'/includes/bootstrap.php';\n\nrequire_once __DIR__.'/helpers/EpisodeFactory.php';\nrequire_once __DIR__.'/helpers/module.php';\nrequire_once __DIR__.'/helpers/db.php';\n"
  },
  {
    "path": "tests/phpunit/helpers/EpisodeFactory.php",
    "content": "<?php\n\nclass EpisodeFactory\n{\n    private $defaults;\n    private $factory;\n\n    public function __construct(WP_UnitTest_Factory $factory)\n    {\n        $this->factory = $factory;\n        $this->defaults = [\n            'enable' => 1,\n            'slug' => 'episode'.wp_rand(1, 10000),\n        ];\n    }\n\n    public function create($args = [])\n    {\n        if (!isset($args['post_id']) || !$args['post_id']) {\n            $post_factory = new WP_UnitTest_Factory_For_Post($this->factory);\n            $args['post_id'] = $post_factory->create(['post_type' => 'podcast']);\n        } else {\n            wp_update_post([\n                'ID' => $args['post_id'],\n                'post_type' => 'podcast',\n            ]);\n        }\n\n        $existing = \\Podlove\\Model\\Episode::find_one_by_property('post_id', $args['post_id']);\n        if ($existing) {\n            return $existing;\n        }\n\n        $args = array_merge($this->defaults, $args);\n\n        return \\Podlove\\Model\\Episode::create($args);\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/helpers/db.php",
    "content": "<?php\n\nfunction podlove_test_truncate_seasons_table(): void\n{\n    if (!\\Podlove\\Modules\\Seasons\\Model\\Season::table_exists()) {\n        return;\n    }\n\n    global $wpdb;\n    $wpdb->query('TRUNCATE TABLE '.\\Podlove\\Modules\\Seasons\\Model\\Season::table_name());\n}\n\nfunction podlove_test_reset_podcast_episodes(): void\n{\n    $posts = get_posts([\n        'post_type' => 'podcast',\n        'post_status' => 'any',\n        'numberposts' => -1,\n    ]);\n\n    foreach ($posts as $post) {\n        wp_delete_post($post->ID, true);\n    }\n\n    if (!\\Podlove\\Model\\Episode::table_exists()) {\n        return;\n    }\n\n    global $wpdb;\n    $wpdb->query('TRUNCATE TABLE '.\\Podlove\\Model\\Episode::table_name());\n}\n"
  },
  {
    "path": "tests/phpunit/helpers/module.php",
    "content": "<?php\n\nfunction podlove_test_activate_module(string $module_name, ?string $module_class = null): void\n{\n    $modules = get_option('podlove_active_modules');\n    if (!is_array($modules)) {\n        add_option('podlove_active_modules', []);\n    }\n\n    if ($module_class && class_exists($module_class)) {\n        $module_class::instance()->load();\n    }\n\n    \\Podlove\\Modules\\Base::deactivate($module_name);\n    \\Podlove\\Modules\\Base::activate($module_name);\n}\n"
  },
  {
    "path": "tests/phpunit/integration/FeedHtmlOptimizationTest.php",
    "content": "<?php\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass FeedHtmlOptimizationTest extends WP_UnitTestCase\n{\n    public function testPrepareContentEncodedLeavesMarkupUntouchedWhenOptimizationIsDisabled()\n    {\n        $content = '<p><a href=\"https://example.com\" class=\"button\" id=\"cta\" aria-label=\"Open\">Link</a></p>';\n\n        $prepared = \\Podlove\\Feeds\\prepare_content_encoded($content, false);\n\n        $this->assertSame($content, $prepared);\n    }\n\n    public function testPrepareContentEncodedRemovesNonEssentialAttributesWhenOptimizationIsEnabled()\n    {\n        $content = '<p><a href=\"https://example.com\" class=\"button\" id=\"cta\" aria-label=\"Open\">Link</a></p>';\n\n        $prepared = \\Podlove\\Feeds\\prepare_content_encoded($content, true);\n\n        $this->assertStringContainsString('<a href=\"https://example.com\">Link</a>', $prepared);\n        $this->assertStringNotContainsString('class=', $prepared);\n        $this->assertStringNotContainsString('id=', $prepared);\n        $this->assertStringNotContainsString('aria-label=', $prepared);\n    }\n\n    public function testPrepareContentEncodedKeepsEssentialImageAttributes()\n    {\n        $content = '<p><img src=\"https://example.com/image.jpg\" alt=\"Cover\" class=\"cover\" loading=\"lazy\" aria-hidden=\"true\"></p>';\n\n        $prepared = \\Podlove\\Feeds\\prepare_content_encoded($content, true);\n\n        $this->assertStringContainsString('<img src=\"https://example.com/image.jpg\" alt=\"Cover\"', $prepared);\n        $this->assertStringNotContainsString('class=', $prepared);\n        $this->assertStringNotContainsString('loading=', $prepared);\n        $this->assertStringNotContainsString('aria-hidden=', $prepared);\n    }\n\n    public function testPrepareContentEncodedAlwaysRemovesStyleTags()\n    {\n        $content = '<style>.note{color:red}</style><p>Shownotes</p>';\n\n        $prepared = \\Podlove\\Feeds\\prepare_content_encoded($content, false);\n\n        $this->assertSame('<p>Shownotes</p>', $prepared);\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/ModuleUninstallTest.php",
    "content": "<?php\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass ModuleUninstallTest extends WP_UnitTestCase\n{\n    protected function tearDown(): void\n    {\n        \\Podlove\\Modules\\Social\\Model\\Service::destroy();\n        \\Podlove\\Modules\\Social\\Model\\ShowService::destroy();\n        \\Podlove\\Modules\\Social\\Model\\ContributorService::destroy();\n        \\Podlove\\Modules\\Shownotes\\Model\\Entry::destroy();\n        \\Podlove\\Modules\\Seasons\\Model\\Season::destroy();\n        \\Podlove\\Modules\\AnalyticsHeartbeat\\Model\\Heartbeat::destroy();\n\n        \\Podlove\\Modules\\Networks\\Model\\PodcastList::with_network_scope(function () {\n            \\Podlove\\Modules\\Networks\\Model\\PodcastList::destroy();\n        });\n\n        parent::tearDown();\n    }\n\n    public function testSocialUninstallRemovesAllModuleTables()\n    {\n        \\Podlove\\Modules\\Social\\Model\\Service::build();\n        \\Podlove\\Modules\\Social\\Model\\ShowService::build();\n        \\Podlove\\Modules\\Social\\Model\\ContributorService::build();\n\n        $this->assertTrue(\\Podlove\\Modules\\Social\\Model\\Service::table_exists());\n        $this->assertTrue(\\Podlove\\Modules\\Social\\Model\\ShowService::table_exists());\n        $this->assertTrue(\\Podlove\\Modules\\Social\\Model\\ContributorService::table_exists());\n\n        \\Podlove\\Modules\\Social\\Social::instance()->uninstall();\n\n        $this->assertFalse(\\Podlove\\Modules\\Social\\Model\\Service::table_exists());\n        $this->assertFalse(\\Podlove\\Modules\\Social\\Model\\ShowService::table_exists());\n        $this->assertFalse(\\Podlove\\Modules\\Social\\Model\\ContributorService::table_exists());\n    }\n\n    public function testShownotesUninstallRemovesEntryTable()\n    {\n        \\Podlove\\Modules\\Shownotes\\Model\\Entry::build();\n\n        $this->assertTrue(\\Podlove\\Modules\\Shownotes\\Model\\Entry::table_exists());\n\n        \\Podlove\\Modules\\Shownotes\\Shownotes::instance()->uninstall();\n\n        $this->assertFalse(\\Podlove\\Modules\\Shownotes\\Model\\Entry::table_exists());\n    }\n\n    public function testSeasonsUninstallRemovesSeasonTable()\n    {\n        \\Podlove\\Modules\\Seasons\\Model\\Season::build();\n\n        $this->assertTrue(\\Podlove\\Modules\\Seasons\\Model\\Season::table_exists());\n\n        \\Podlove\\Modules\\Seasons\\Seasons::instance()->uninstall();\n\n        $this->assertFalse(\\Podlove\\Modules\\Seasons\\Model\\Season::table_exists());\n    }\n\n    public function testAnalyticsHeartbeatUninstallRemovesHeartbeatTable()\n    {\n        \\Podlove\\Modules\\AnalyticsHeartbeat\\Model\\Heartbeat::build();\n\n        $this->assertTrue(\\Podlove\\Modules\\AnalyticsHeartbeat\\Model\\Heartbeat::table_exists());\n\n        \\Podlove\\Modules\\AnalyticsHeartbeat\\Analytics_Heartbeat::instance()->uninstall();\n\n        $this->assertFalse(\\Podlove\\Modules\\AnalyticsHeartbeat\\Model\\Heartbeat::table_exists());\n    }\n\n    public function testNetworksUninstallRemovesNetworkPodcastListTable()\n    {\n        \\Podlove\\Modules\\Networks\\Model\\PodcastList::with_network_scope(function () {\n            \\Podlove\\Modules\\Networks\\Model\\PodcastList::build();\n        });\n\n        $this->assertTrue($this->networkPodcastListTableExists());\n\n        \\Podlove\\Modules\\Networks\\Networks::instance()->uninstall();\n\n        $this->assertFalse($this->networkPodcastListTableExists());\n    }\n\n    private function networkPodcastListTableExists(): bool\n    {\n        global $wpdb;\n\n        return \\Podlove\\Modules\\Networks\\Model\\PodcastList::with_network_scope(function () use ($wpdb) {\n            $sql = $wpdb->prepare('SHOW TABLES LIKE %s', \\Podlove\\esc_like(\\Podlove\\Modules\\Networks\\Model\\PodcastList::table_name()));\n\n            return $wpdb->get_var($sql) !== null;\n        });\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/PluginActivationTest.php",
    "content": "<?php\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass PluginActivationTest extends WP_UnitTestCase\n{\n    public function testActivationEnablesDefaultModules()\n    {\n        delete_option('podlove_active_modules');\n\n        $result = activate_plugin('podlove-podcasting-plugin-for-wordpress/podlove.php');\n        $this->assertNull($result, 'Plugin activation failed.');\n\n        $expected_modules = [\n            'logging',\n            'podlove_web_player',\n            'open_graph',\n            'plus',\n            'oembed',\n            'import_export',\n            'subscribe_button',\n            'automatic_numbering',\n            'onboarding',\n        ];\n\n        foreach ($expected_modules as $module) {\n            $this->assertTrue(\n                \\Podlove\\Modules\\Base::is_active($module),\n                sprintf('Expected module \"%s\" to be active after activation.', $module)\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/PlusFeedProxyTest.php",
    "content": "<?php\n\nuse Podlove\\Model\\Podcast;\nuse Podlove\\Modules\\Base;\nuse Podlove\\Modules\\Plus\\FeedProxy;\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass PlusFeedProxyTest extends WP_UnitTestCase\n{\n    private $was_plus_active;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->was_plus_active = Base::is_active('plus');\n        podlove_test_activate_module('plus', \\Podlove\\Modules\\Plus\\Plus::class);\n    }\n\n    public function tearDown(): void\n    {\n        $podcast = Podcast::get();\n        $podcast->plus_enable_proxy = false;\n        $podcast->save();\n\n        if ($this->was_plus_active) {\n            Base::activate('plus');\n        } else {\n            Base::deactivate('plus');\n        }\n\n        parent::tearDown();\n    }\n\n    public function testIsEnabledRequiresPlusModule()\n    {\n        $podcast = Podcast::get();\n        $podcast->plus_enable_proxy = true;\n        $podcast->save();\n\n        Base::deactivate('plus');\n        $this->assertFalse(FeedProxy::is_enabled());\n\n        Base::activate('plus');\n        $this->assertTrue(FeedProxy::is_enabled());\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/SeasonsTest.php",
    "content": "<?php\n\nuse Podlove\\Modules\\Seasons\\Model\\Season;\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass SeasonsTest extends WP_UnitTestCase\n{\n    private $episode_factory;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        podlove_test_reset_podcast_episodes();\n\n        podlove_test_activate_module('seasons', \\Podlove\\Modules\\Seasons\\Seasons::class);\n        $this->episode_factory = new EpisodeFactory($this->factory);\n    }\n\n    public function tearDown(): void\n    {\n        podlove_test_truncate_seasons_table();\n        podlove_test_reset_podcast_episodes();\n        parent::tearDown();\n    }\n\n    public function testSeasonsAreNumberedCorrectly()\n    {\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n        $season2 = Season::create(['start_date' => '2012-01-01']);\n        $season3 = Season::create(['start_date' => '2013-01-01']);\n\n        $this->assertEquals($season1->number(), 1);\n        $this->assertEquals($season2->number(), 2);\n        $this->assertEquals($season3->number(), 3);\n    }\n\n    public function testPreviousSeason()\n    {\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n        $season2 = Season::create(['start_date' => '2012-01-01']);\n\n        $this->assertEquals($season2->previous_season()->id, $season1->id);\n        $this->assertNull($season1->previous_season());\n    }\n\n    public function testNextSeason()\n    {\n        $season0 = Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n        $season2 = Season::create(['start_date' => '2012-01-01']);\n\n        $this->assertEquals($season0->next_season()->id, $season1->id);\n        $this->assertEquals($season1->next_season()->id, $season2->id);\n        $this->assertNull($season2->next_season());\n    }\n\n    public function testEpisodesForSingleSeason()\n    {\n        $season = Season::create();\n\n        $this->episode_factory->create();\n        $this->episode_factory->create();\n\n        $this->assertEquals(2, count($season->episodes()));\n    }\n\n    public function testEpisodesForFirstSeason()\n    {\n        $season0 = Season::create();\n        Season::create(['start_date' => '2011-01-01']);\n\n        $this->_generate_episodes_for_dates([\n            '2010-10-10',\n            '2010-10-09',\n            '2012-10-09',\n        ]);\n\n        $this->assertEquals(2, count($season0->episodes()));\n    }\n\n    public function testEpisodesForRunningSeason()\n    {\n        Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n\n        $this->_generate_episodes_for_dates([\n            '2010-10-10',\n            '2013-10-09',\n            '2012-10-09',\n        ]);\n\n        $this->assertEquals(2, count($season1->episodes()));\n    }\n\n    public function testEpisodesForInbetweenSeason()\n    {\n        Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n        Season::create(['start_date' => '2014-01-01']);\n\n        $this->_generate_episodes_for_dates([\n            '2010-10-10',\n            '2011-02-02',\n            '2011-04-10',\n            '2015-10-09',\n        ]);\n\n        $this->assertEquals(2, count($season1->episodes()));\n    }\n\n    public function testEpisodesFreakShow()\n    {\n        $season1 = Season::create(['start_date' => '2008-04-01']);\n        $season2 = Season::create(['start_date' => '2014-08-08']);\n\n        $this->_generate_episodes_for_dates([\n            '2008-04-01 19:33',\n            '2013-07-11 01:29',\n            '2015-07-16 02:13',\n        ]);\n\n        $this->assertEquals(2, count($season1->episodes()), 'mobileMacs');\n        $this->assertEquals(1, count($season2->episodes()), 'Freak Show');\n    }\n\n    public function testCurrentSeasonHasNoEndDate()\n    {\n        $season = Season::create();\n        $this->episode_factory->create();\n\n        $this->assertTrue($season->is_running());\n        $this->assertNull($season->end_date());\n    }\n\n    public function testEndDateOfSeason()\n    {\n        $season0 = Season::create();\n        Season::create(['start_date' => '2011-01-01']);\n\n        $this->_generate_episodes_for_dates(['2010-10-10']);\n\n        $this->assertEquals('2010-10-10', $season0->end_date('Y-m-d'));\n    }\n\n    public function testLastEpisode()\n    {\n        $season0 = Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n\n        $episodes = $this->_generate_episodes_for_dates(['2010-10-10', '2010-10-11', '2013-01-01']);\n\n        $this->assertEquals($episodes[1]->id, $season0->last_episode()->id);\n        $this->assertEquals($episodes[2]->id, $season1->last_episode()->id);\n    }\n\n    public function testFirstEpisode()\n    {\n        $season0 = Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n\n        $episodes = $this->_generate_episodes_for_dates([\n            '2010-10-10',\n            '2010-10-11 10:00',\n            '2013-01-01',\n        ]);\n\n        $this->assertEquals($episodes[0]->id, $season0->first_episode()->id);\n        $this->assertEquals($episodes[2]->id, $season1->first_episode()->id);\n    }\n\n    public function testGetByDate()\n    {\n        $season0 = Season::create();\n        $season1 = Season::create(['start_date' => '2011-01-01']);\n\n        $this->_generate_episodes_for_dates([\n            '2010-10-10',\n            '2010-10-11',\n            '2013-01-01',\n        ]);\n\n        $this->assertNull(Season::by_date(strtotime('2005-10-10')));\n        $this->assertEquals($season0->id, Season::by_date(strtotime('2010-10-10'))->id);\n        $this->assertEquals($season1->id, Season::by_date(strtotime('2013-01-02'))->id);\n    }\n\n    private function _generate_episodes_for_dates(array $dates)\n    {\n        $episodes = [];\n\n        foreach ($dates as $date) {\n            $episodes[] = $this->episode_factory->create([\n                'post_id' => $this->factory->post->create(['post_date' => date('Y-m-d H:i:s', strtotime($date))]),\n            ]);\n        }\n\n        return $episodes;\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/SeasonsValidatorTest.php",
    "content": "<?php\n\nuse Podlove\\Modules\\Seasons\\Model\\Season;\nuse Podlove\\Modules\\Seasons\\Model\\SeasonsValidator;\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass SeasonsValidatorTest extends WP_UnitTestCase\n{\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        podlove_test_activate_module('seasons', \\Podlove\\Modules\\Seasons\\Seasons::class);\n    }\n\n    public function tearDown(): void\n    {\n        podlove_test_truncate_seasons_table();\n        parent::tearDown();\n    }\n\n    public function testDetectsMultipleFirstSeasons()\n    {\n        Season::create();\n        Season::create();\n\n        $validator = new SeasonsValidator();\n        $validator->validate();\n        $issues = $validator->issues();\n\n        $this->assertEquals(1, count($issues));\n\n        $issue = $issues[0];\n\n        $this->assertEquals('multiple_first_seasons', $issue->type);\n        $this->assertEquals('Only one season can have an empty start date.', $issue->message());\n    }\n\n    public function testDetectsDuplicateStartDates()\n    {\n        Season::create(['start_date' => '2011-01-01']);\n        Season::create(['start_date' => '2011-01-01']);\n\n        $validator = new SeasonsValidator();\n        $validator->validate();\n        $issues = $validator->issues();\n\n        $this->assertEquals(1, count($issues));\n\n        $issue = $issues[0];\n\n        $this->assertEquals('duplicate_start_dates', $issue->type);\n        $this->assertEquals('Some of your seasons have the same start date.', $issue->message());\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/integration/SlackMessageTest.php",
    "content": "<?php\n\nuse Podlove\\Modules\\SlackShownotes\\Message;\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass SlackMessageTest extends WP_UnitTestCase\n{\n    public function testExtractLinkMatchedWithAttachment()\n    {\n        $data = <<<'EOT'\n{\n  \"client_msg_id\": \"a651c38b-2bc3-4ce3-8a48-2b131af24887\",\n  \"type\": \"message\",\n  \"text\": \"<https://freakshow.fm/fs228-letty-im-datenteich>\",\n  \"user\": \"UF53JT12T\",\n  \"ts\": \"1546257135.000400\",\n  \"attachments\": [\n      {\n          \"service_name\": \"Freak Show\",\n          \"title\": \"FS228 Letty im Datenteich\",\n          \"title_link\": \"https://freakshow.fm/fs228-letty-im-datenteich\",\n          \"text\": \"...\",\n          \"fallback\": \"Freak Show: FS228 Letty im Datenteich\",\n          \"thumb_url\": \"https://meta.metaebene.me/media/mm/freakshow-logo-1.0.jpg\",\n          \"from_url\": \"https://freakshow.fm/fs228-letty-im-datenteich\",\n          \"thumb_width\": 1400,\n          \"thumb_height\": 1400,\n          \"service_icon\": \"https://freakshow.fm/files/2013/07/cropped-freakshow-logo-600x600-180x180.jpg\",\n          \"id\": 1,\n          \"original_url\": \"https://freakshow.fm/fs228-letty-im-datenteich\"\n      }\n  ]\n}\nEOT;\n\n        $message = json_decode($data, true);\n        $result = Message::extract_links($message);\n\n        $this->assertEquals(1, count($result));\n        $this->assertEquals($result[0]['link'], 'https://freakshow.fm/fs228-letty-im-datenteich');\n        $this->assertEquals($result[0]['title'], 'FS228 Letty im Datenteich');\n        $this->assertEquals($result[0]['source'], 'Freak Show');\n    }\n\n    public function testLinkWithMissingAttachment()\n    {\n        $data = <<<'EOT'\n{\n    \"client_msg_id\": \"5510681d-1c33-41aa-a0ee-62779cd9e8ad\",\n    \"type\": \"message\",\n    \"text\": \"Link mit gecancelter expansion <http://www.spiegel.de/politik/ausland/kongos-regierung-kappt-das-internet-nach-praesidentenwahl-a-1245955.html>\",\n    \"user\": \"UF53JT12T\",\n    \"ts\": \"1546266336.001000\"\n}\nEOT;\n\n        $message = json_decode($data, true);\n        $result = Message::extract_links($message);\n\n        $this->assertEquals(1, count($result));\n        $this->assertEquals($result[0]['link'], 'http://www.spiegel.de/politik/ausland/kongos-regierung-kappt-das-internet-nach-praesidentenwahl-a-1245955.html');\n        $this->assertEquals($result[0]['title'], null);\n        $this->assertEquals($result[0]['source'], 'spiegel.de');\n    }\n\n    public function testLinkWithPipes()\n    {\n        $data = <<<'EOT'\n{\n    \"client_msg_id\": \"5510681d-1c33-41aa-a0ee-62779cd9e8ad\",\n    \"type\": \"message\",\n    \"text\": \"Link mit pipe <https://golem.de|golem.de>\",\n    \"user\": \"UF53JT12T\",\n    \"ts\": \"1546266336.001000\"\n}\nEOT;\n\n        $message = json_decode($data, true);\n        $result = Message::extract_links($message);\n\n        $this->assertEquals(1, count($result));\n        $this->assertEquals($result[0]['link'], 'https://golem.de');\n        $this->assertEquals($result[0]['title'], null);\n        $this->assertEquals($result[0]['source'], 'golem.de');\n    }\n\n    public function testMultipleLinksWithAttachments()\n    {\n        $data = <<<'EOT'\n{\n  \"client_msg_id\": \"7b5c8c33-be8f-4974-84ea-14ccbc4bc4aa\",\n  \"type\": \"message\",\n  \"text\": \"Ein Link hier <https://www.zeit.de/gesellschaft/zeitgeschehen/2018-12/vatikan-papst-franziskus-sprecher-ruecktritt> und da <https://www.zeit.de/2019/01/demokratieverdrossenheit-misstrauen-aufschwung-buerger-generationenkonflikt>\",\n  \"user\": \"UF53JT12T\",\n  \"ts\": \"1546277254.000300\",\n  \"attachments\": [\n      {\n          \"service_name\": \"ZEIT ONLINE\",\n          \"title\": \"Vatikan: Sprecher von Papst Franziskus treten zurück\",\n          \"title_link\": \"https://www.zeit.de/gesellschaft/zeitgeschehen/2018-12/vatikan-papst-franziskus-sprecher-ruecktritt\",\n          \"text\": \"...\",\n          \"image_url\": \"https://img.zeit.de/gesellschaft/2018-12/pressesprecher-papst-ruecktritt-burke-ovejero/wide__1300x731\",\n          \"image_width\": 445,\n          \"image_height\": 250,\n          \"from_url\": \"https://www.zeit.de/gesellschaft/zeitgeschehen/2018-12/vatikan-papst-franziskus-sprecher-ruecktritt\",\n          \"image_bytes\": 159999,\n          \"service_icon\": \"https://img.zeit.de/static/img/ZO-ipad-114x114.png\",\n          \"id\": 1,\n          \"original_url\": \"https://www.zeit.de/gesellschaft/zeitgeschehen/2018-12/vatikan-papst-franziskus-sprecher-ruecktritt\"\n      },\n      {\n          \"service_name\": \"ZEIT ONLINE\",\n          \"title\": \"Demokratieverdrossenheit: Warum trauen so viele der Demokratie nicht, obwohl wir einen Aufschwung erleben?\",\n          \"title_link\": \"https://www.zeit.de/2019/01/demokratieverdrossenheit-misstrauen-aufschwung-buerger-generationenkonflikt\",\n          \"text\": \"...\",\n          \"image_url\": \"https://img.zeit.de/politik/2018-12/demokratie-misstrauen-aufschwung/wide__1300x731\",\n          \"image_width\": 445,\n          \"image_height\": 250,\n          \"from_url\": \"https://www.zeit.de/2019/01/demokratieverdrossenheit-misstrauen-aufschwung-buerger-generationenkonflikt\",\n          \"image_bytes\": 141328,\n          \"service_icon\": \"https://img.zeit.de/static/img/ZO-ipad-114x114.png\",\n          \"id\": 2,\n          \"original_url\": \"https://www.zeit.de/2019/01/demokratieverdrossenheit-misstrauen-aufschwung-buerger-generationenkonflikt\"\n      }\n  ]\n}\nEOT;\n\n        $message = json_decode($data, true);\n        $result = Message::extract_links($message);\n\n        $this->assertEquals(2, count($result));\n        $this->assertEquals($result[0]['link'], 'https://www.zeit.de/gesellschaft/zeitgeschehen/2018-12/vatikan-papst-franziskus-sprecher-ruecktritt');\n        $this->assertEquals($result[0]['title'], 'Vatikan: Sprecher von Papst Franziskus treten zurück');\n        $this->assertEquals($result[1]['link'], 'https://www.zeit.de/2019/01/demokratieverdrossenheit-misstrauen-aufschwung-buerger-generationenkonflikt');\n        $this->assertEquals($result[1]['title'], 'Demokratieverdrossenheit: Warum trauen so viele der Demokratie nicht, obwohl wir einen Aufschwung erleben?');\n    }\n}\n"
  },
  {
    "path": "tests/phpunit/rest/EpisodesApiTest.php",
    "content": "<?php\n\nuse Podlove\\Model\\Episode;\n\n/**\n * @internal\n *\n * @coversNothing\n */\nclass EpisodesApiTest extends WP_UnitTestCase\n{\n    private $server;\n    private $admin_user_id;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        $this->server = rest_get_server();\n\n        podlove_setup_database_tables();\n        podlove_test_reset_podcast_episodes();\n\n        $this->admin_user_id = $this->factory->user->create(['role' => 'administrator']);\n        wp_set_current_user($this->admin_user_id);\n    }\n\n    public function tearDown(): void\n    {\n        podlove_test_reset_podcast_episodes();\n        parent::tearDown();\n    }\n\n    public function testCreateEpisode()\n    {\n        $create_request = new WP_REST_Request('POST', '/podlove/v2/episodes');\n        $create_response = $this->server->dispatch($create_request);\n\n        $this->assertEquals(201, $create_response->get_status());\n        $create_data = $create_response->get_data();\n        $this->assertArrayHasKey('id', $create_data);\n\n        $episode_id = (int) $create_data['id'];\n        $episode = Episode::find_by_id($episode_id);\n        $this->assertNotNull($episode);\n    }\n\n    public function testUpdateEpisode()\n    {\n        $episode = $this->create_episode();\n\n        $update_request = new WP_REST_Request('PUT', '/podlove/v2/episodes/'.$episode->id);\n        $update_request->set_param('title', 'Updated Episode Title');\n        $update_request->set_param('subtitle', 'Updated Episode Subtitle');\n        $update_request->set_param('summary', 'Updated Episode Summary');\n        $update_request->set_param('number', 42);\n        $update_request->set_param('explicit', true);\n        $update_request->set_param('soundbite_title', 'Intro');\n        $update_request->set_param('soundbite_start', '00:00:10');\n        $update_request->set_param('soundbite_duration', '00:00:30');\n\n        $update_response = $this->server->dispatch($update_request);\n        $this->assertEquals(200, $update_response->get_status());\n\n        $episode = Episode::find_by_id($episode->id);\n        $this->assertEquals('Updated Episode Title', $episode->title);\n        $this->assertEquals('Updated Episode Subtitle', $episode->subtitle);\n        $this->assertEquals('Updated Episode Summary', $episode->summary);\n        $this->assertEquals(42, $episode->number);\n        $this->assertEquals(1, $episode->explicit);\n        $this->assertEquals('Intro', $episode->soundbite_title);\n        $this->assertEquals('00:00:10', $episode->soundbite_start);\n        $this->assertEquals('00:00:30', $episode->soundbite_duration);\n\n        $post = get_post($episode->post_id);\n        $this->assertEquals('Updated Episode Title', $post->post_title);\n    }\n\n    public function testDeleteEpisode()\n    {\n        $episode = $this->create_episode();\n\n        $delete_request = new WP_REST_Request('DELETE', '/podlove/v2/episodes/'.$episode->id);\n        $delete_response = $this->server->dispatch($delete_request);\n        $this->assertEquals(200, $delete_response->get_status());\n\n        $post = get_post($episode->post_id);\n        $this->assertEquals('trash', $post->post_status);\n    }\n\n    public function testCreateEpisodeRequiresPermissions()\n    {\n        wp_set_current_user(0);\n\n        $request = new WP_REST_Request('POST', '/podlove/v2/episodes');\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(401, $response->get_status());\n    }\n\n    public function testUpdateEpisodeRequiresPermissions()\n    {\n        $episode = $this->create_episode();\n        wp_set_current_user(0);\n\n        $request = new WP_REST_Request('PUT', '/podlove/v2/episodes/'.$episode->id);\n        $request->set_param('title', 'Unauthorized Update');\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(401, $response->get_status());\n    }\n\n    public function testDeleteEpisodeRequiresPermissions()\n    {\n        $episode = $this->create_episode();\n        wp_set_current_user(0);\n\n        $request = new WP_REST_Request('DELETE', '/podlove/v2/episodes/'.$episode->id);\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(401, $response->get_status());\n    }\n\n    public function testUpdateEpisodeNotFound()\n    {\n        $request = new WP_REST_Request('PUT', '/podlove/v2/episodes/999999');\n        $request->set_param('title', 'Missing Episode');\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(404, $response->get_status());\n    }\n\n    public function testDeleteEpisodeNotFound()\n    {\n        $request = new WP_REST_Request('DELETE', '/podlove/v2/episodes/999999');\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(404, $response->get_status());\n    }\n\n    public function testUpdateEpisodeRejectsInvalidDuration()\n    {\n        $episode = $this->create_episode();\n\n        $request = new WP_REST_Request('PUT', '/podlove/v2/episodes/'.$episode->id);\n        $request->set_param('duration', 'invalid');\n        $response = $this->server->dispatch($request);\n\n        $this->assertEquals(400, $response->get_status());\n    }\n\n    private function create_episode(): Episode\n    {\n        $post_id = wp_insert_post([\n            'post_title' => 'API Test Episode',\n            'post_type' => 'podcast',\n            'post_status' => 'draft',\n        ]);\n\n        return Episode::find_or_create_by_post_id($post_id);\n    }\n}\n"
  },
  {
    "path": "vetur.config.js",
    "content": "module.exports = {\n  projects: [\n    {\n      root: './client',\n    },\n  ],\n}\n"
  },
  {
    "path": "views/expert_settings/website/blog_post_title.php",
    "content": "<input name=\"podlove_website[enable_generated_blog_post_title]\" id=\"enable_generated_blog_post_title\" type=\"checkbox\" <?php checked($enable_generated_blog_post_title, 'on'); ?>> <?php _e('Generate automatically', 'podlove-podcasting-plugin-for-wordpress'); ?>\n<div id=\"custom_episode_blog_post_title\"<?php if (!$enable_generated_blog_post_title) {\n    echo ' style=\"display:none;\"';\n} ?>>\n\t<input name=\"podlove_website[blog_title_template]\" id=\"blog_title_template\" type=\"text\" value=\"<?php echo $blog_title_template; ?>\" class=\"large-text\">\n\t<p class=\"description\">\n\t\t<?php _e('Placeholders', 'podlove-podcasting-plugin-for-wordpress'); ?>: %mnemonic%, %episode_number%, %season_number%, %episode_title%\n\t</p>\n</div>\n\n<script type=\"text/javascript\">\njQuery(function($) {\n\t$(document).ready(function() {\n\n\t\tfunction handle_permastruct_settings() {\n\t\t\tif ( $(\"#enable_generated_blog_post_title\").is( ':checked' ) ) {\n\t\t\t\t$(\"#custom_episode_blog_post_title\").slideDown();\n\t\t\t} else {\n\t\t\t\t$(\"#custom_episode_blog_post_title\").slideUp();\n\t\t\t}\n\t\t}\n\n\t\t$(\"#enable_generated_blog_post_title\").on(\"click\", function(e) {\n\t\t\thandle_permastruct_settings();\n\t\t});\n\n\t\thandle_permastruct_settings();\n\t});\n});\n</script>\n\n<style type=\"text/css\">\n#custom_episode_blog_post_title {\n\tmargin-top: 10px;\n}\n</style>\n"
  },
  {
    "path": "views/expert_settings/website/custom_episode_slug.php",
    "content": "<input name=\"podlove_website[use_post_permastruct]\" id=\"use_post_permastruct\" type=\"checkbox\" <?php checked($use_post_permastruct, 'on'); ?>> <?php _e('Use the same permalink structure as posts', 'podlove-podcasting-plugin-for-wordpress'); ?>\n<div id=\"custom_podcast_permastruct\"<?php if ($use_post_permastruct) {\n    echo ' style=\"display:none;\"';\n} ?>>\n\t<code><?php echo get_option('home'); ?></code>\n\t<input name=\"podlove_website[custom_episode_slug]\" id=\"custom_episode_slug\" type=\"text\" value=\"<?php echo $custom_episode_slug; ?>\">\n\t<p><span class=\"description\">\n\t\t<?php _e('Placeholders', 'podlove-podcasting-plugin-for-wordpress'); ?>: %podcast% (<?php _e('episode slug', 'podlove-podcasting-plugin-for-wordpress'); ?>), %post_id%, %year%, %monthnum%, %day%, %hour%, %minute%, %second%, %category%, %author%<br>\n\t\t<?php _e('Example schemes', 'podlove-podcasting-plugin-for-wordpress'); ?>: <code>/%podcast%</code>, <code>/episode/%podcast%</code>, <code>/%year%/%monthnum%/%podcast%</code>\n\t</span></p>\n</div>\n\n<script type=\"text/javascript\">\njQuery(function($) {\n\t$(document).ready(function() {\n\n\t\tfunction handle_permastruct_settings() {\n\t\t\tif ( $(\"#use_post_permastruct\").is( ':checked' ) ) {\n\t\t\t\t$(\"#custom_podcast_permastruct\").slideUp();\n\t\t\t} else {\n\t\t\t\t$(\"#custom_podcast_permastruct\").slideDown();\n\t\t\t}\n\t\t}\n\n\t\t$(\"#use_post_permastruct\").on(\"click\", function(e) {\n\t\t\thandle_permastruct_settings();\n\t\t});\n\n\t\thandle_permastruct_settings();\n\t});\n});\n</script>\n\n<style type=\"text/css\">\n#custom_podcast_permastruct {\n\tmargin-top: 10px;\n}\n</style>\n"
  },
  {
    "path": "views/expert_settings/website/episode_archive.php",
    "content": "<input name=\"podlove_website[episode_archive]\" id=\"episode_archive\" type=\"checkbox\" <?php checked($enable_episode_archive, 'on'); ?>> <?php _e('Enable episode pages: a complete, paginated list of episodes, sorted by publishing date.', 'podlove-podcasting-plugin-for-wordpress'); ?>\n<div id=\"episode_archive_slug_edit\"<?php if (!$enable_episode_archive) {\n    echo ' style=\"display:none;\"';\n} ?>>\n\t<code><?php echo get_option('siteurl').$blog_prefix; ?></code>\n\t<input class=\"podlove-check-input\" name=\"podlove_website[episode_archive_slug]\" id=\"episode_archive_slug\" type=\"text\" value=\"<?php echo $episode_archive_slug; ?>\">\n</div>\n\n<script type=\"text/javascript\">\njQuery(function($) {\n\t$(document).ready(function() {\n\t\t$(\"#episode_archive\").on(\"click\", function(e) {\n\t\t\tif ( $(this).is( ':checked' ) ) {\n\t\t\t\t$(\"#episode_archive_slug_edit\").slideDown();\n\t\t\t} else {\n\t\t\t\t$(\"#episode_archive_slug_edit\").slideUp();\n\t\t\t}\n\t\t});\n\t});\n});\n</script>\n\n<style type=\"text/css\">\n#episode_archive_slug_edit {\n\tmargin-top: 10px;\n}\n</style>\n"
  },
  {
    "path": "views/expert_settings/website/landing_page.php",
    "content": "<select name=\"podlove_website[landing_page]\" id=\"landing_page\">\n\t<?php foreach ($landing_page_options as $option) { ?>\n\t\t<option\n\t\t\t<?php if (isset($option['value'])) { ?>\n\t\t\t\tvalue=\"<?php echo $option['value']; ?>\"\n\t\t\t\t<?php if ($landing_page == $option['value']) { ?> selected<?php } ?>\n\t\t\t<?php } ?>\n\t\t\t<?php if (isset($option['disabled']) && $option['disabled']) { ?> disabled<?php } ?>\n\t\t>\n\t\t\t<?php echo $option['text']; ?>\n\t\t</option>\n\t<?php } ?>\n</select>\n\n<script type=\"text/javascript\">\njQuery(function($) {\n\t$(document).ready(function() {\n\t\tvar maybe_toggle_episode_archive_option = function() {\n\t\t\tvar $archive = $(\"#episode_archive\"),\n\t\t\t\t$archive_option = $(\"#landing_page option:eq(1)\"),\n\t\t\t\t$home_option = $(\"#landing_page option:eq(0)\");\n\n\t\t\tif ($archive.is(':checked')) {\n\t\t\t\t$archive_option.attr('disabled', false);\n\t\t\t} else {\n\t\t\t\t$archive_option.attr('disabled', 'disabled');\n\t\t\t\t// if it was selected before, unselect it\n\t\t\t\tif ($archive_option.attr('selected') == 'selected') {\n\t\t\t\t\t$archive_option.attr('selected', false);\n\t\t\t\t\t$home_option.attr('selected', 'selected');\n\t\t\t\t}\n\t\t\t}\n\n\t\t};\n\n\t\t$(\"#episode_archive\").on(\"click\", function(e) {\n\t\t\tmaybe_toggle_episode_archive_option();\n\t\t});\n\n\t\tmaybe_toggle_episode_archive_option();\n\t});\n});\n</script>\n<?php\necho __('This defines the landing page to your podcast. It is the site that your podcast feeds link to.', 'podlove-podcasting-plugin-for-wordpress');\n"
  },
  {
    "path": "views/settings/dashboard/about.php",
    "content": "<ul>\n\t<li>\n\t\t<a href=\"https://podlove.org/podlove-podcast-publisher/\"><?php _e('Podlove Publisher', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"//podlove.org\" target=\"_blank\"><?php _e('Podlove Initiative', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"//community.podlove.org/\" target=\"_blank\"><?php _e('Podlove Community', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"//docs.podlove.org\" target=\"_blank\"><?php _e('Documentation &amp; Guides', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"<?php echo admin_url('admin.php?page=podlove_Support_settings_handle'); ?>\"><?php _e('Report Bugs', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"https://opencollective.com/podlove\" target=\"_blank\"><?php _e('Donate', 'podlove-podcasting-plugin-for-wordpress'); ?></a>\n\t</li>\n\t<li>\n\t\t<a href=\"http://www.cornify.com\" onclick=\"cornify_add();return false;\" style=\"text-decoration: none; color: #A7A7A7; font-size: 20px; line-height: 20px;\"><i class=\"podlove-icon-heart\"></i></a>\n\t</li>\n</ul>\n"
  },
  {
    "path": "views/settings/dashboard/dashboard.php",
    "content": "<div class=\"wrap\">\n\t<h2><?php echo __('Podlove Dashboard', 'podlove-podcasting-plugin-for-wordpress'); ?></h2>\n\n\t<div id=\"poststuff\" class=\"metabox-holder has-right-sidebar\">\n\t\t\n\t\t<!-- sidebar -->\n\t\t<div id=\"side-info-column\" class=\"inner-sidebar\">\n\t\t\t<?php do_action('podlove_settings_before_sidebar_boxes'); ?>\n\t\t\t<?php do_meta_boxes(\\Podlove\\Settings\\Dashboard::$pagehook, 'side', null); ?>\n\t\t\t<?php do_action('podlove_settings_after_sidebar_boxes'); ?>\n\t\t</div>\n\n\t\t<!-- main -->\n\t\t<div id=\"post-body\" class=\"has-sidebar\">\n\t\t\t<div id=\"post-body-content\" class=\"has-sidebar-content\">\n\t\t\t\t<?php do_action('podlove_settings_before_main_boxes'); ?>\n\t\t\t\t<?php do_meta_boxes(\\Podlove\\Settings\\Dashboard::$pagehook, 'normal', null); ?>\n\t\t\t\t<?php do_meta_boxes(\\Podlove\\Settings\\Dashboard::$pagehook, 'additional', null); ?>\n\t\t\t\t<?php do_action('podlove_settings_after_main_boxes'); ?>\t\t\t\t\t\t\n\t\t\t</div>\n\t\t</div>\n\n\t\t<br class=\"clear\"/>\n\n\t</div>\n\n\t<!-- Stuff for opening / closing metaboxes -->\n\t<script type=\"text/javascript\">\n\tjQuery( document ).ready( function( $ ){\n\t\t// close postboxes that should be closed\n\t\t$( '.if-js-closed' ).removeClass( 'if-js-closed' ).addClass( 'closed' );\n\t\t// postboxes setup\n\t\tpostboxes.add_postbox_toggles( '<?php echo \\Podlove\\Settings\\Dashboard::$pagehook; ?>' );\n\t} );\n\t</script>\n\n\t<form style='display: none' method='get' action=''>\n\t\t<?php\n        wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false);\n\twp_nonce_field('meta-box-order', 'meta-box-order-nonce', false);\n\t?>\n\t</form>\n\n</div>\n"
  },
  {
    "path": "views/settings/dashboard/file_validation.php",
    "content": "<?php\ndefine('ASSET_STATUS_OK', '<i class=\"clickable podlove-icon-ok\"></i>');\ndefine('ASSET_STATUS_INACTIVE', '<i class=\"podlove-icon-minus\"></i>');\ndefine('ASSET_STATUS_ERROR', '<i class=\"clickable podlove-icon-remove\"></i>');\n?>\n\n<div id=\"asset_validation\">\n\t<input id=\"revalidate_assets\" type=\"button\" class=\"button button-primary\" value=\"<?php echo __('Revalidate Assets', 'podlove-podcasting-plugin-for-wordpress'); ?>\">\n\n\t<table id=\"asset_status_dashboard\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<?php foreach ($header as $column_head) { ?>\n\t\t\t\t\t<th><?php echo $column_head; ?></th>\n\t\t\t\t<?php } ?>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t<?php foreach ($episodes as $episode) { ?>\n\t\t\t\t<tr>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<a href=\"<?php echo admin_url('post.php?post='.$episode->post_id.'&amp;action=edit'); ?>\">\n\t\t\t\t\t\t\t<?php\n                            if (is_null($episode->slug)) {\n                                echo ASSET_STATUS_INACTIVE;\n                                echo ' ';\n                                echo __('Slug is missing', 'podlove-podcasting-plugin-for-wordpress');\n                            } else {\n                                echo $episode->slug();\n                            }\n\t\t\t    ?>\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</td>\n\t\t\t\t\t<?php foreach ($assets as $asset) { ?>\n\t\t\t\t\t\t<?php\n\t\t\t            if (isset($media_files[$episode->id][$asset->id])) {\n\t\t\t                $file = $media_files[$episode->id][$asset->id];\n\t\t\t            } else {\n\t\t\t                $file = false;\n\t\t\t            }\n\t\t\t\t\t    ?>\n\t\t\t\t\t\t<td class=\"media_file_status\" data-media-file-id=\"<?php echo $file ? $file['media_file_id'] : ''; ?>\">\n\t\t\t\t\t\t\t<?php\n\t\t\t\t\t        if (!$file || !$file['active']) {\n\t\t\t\t\t            echo ASSET_STATUS_INACTIVE;\n\t\t\t\t\t        } elseif ($file['size'] > 0) {\n\t\t\t\t\t            echo ASSET_STATUS_OK;\n\t\t\t\t\t        } else {\n\t\t\t\t\t            echo ASSET_STATUS_ERROR;\n\t\t\t\t\t        }\n\t\t\t\t\t    ?>\n\t\t\t\t\t\t</td>\n\t\t\t\t\t<?php } ?>\n\t\t\t\t\t<td>\n\t\t\t\t\t\t<?php echo get_post_status($episode->post_id); ?>\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t<?php } ?>\n\t\t</tbody>\n\t</table>\n</div>\n\n<style type=\"text/css\">\n.media_file_status {\n\ttext-align: center;\n\tfont-weight: bold; \n\tfont-size: 20px;\n}\n</style>"
  },
  {
    "path": "views/settings/dashboard/news.php",
    "content": "<?php\nrequire_once ABSPATH.'wp-admin/includes/dashboard.php';\n$success = \\wp_dashboard_cached_rss_widget(\n    'podlove_dashboard_news',\n    'wp_dashboard_primary_output',\n    $feeds\n);\n\nif (!$success) { ?>\n<script type=\"text/javascript\">\njQuery.ajax(ajaxurl, {\n\tdataType: 'html',\n\ttype: 'GET',\n\tdata: { action: 'podlove-admin-news' },\n\tsuccess: function(response, status, xhr) {\n\t\tjQuery(\"#toplevel_page_podlove_settings_handle_news .inside\").html(response);\n\t}\n});\n</script>\n<?php }\n"
  },
  {
    "path": "views/settings/dashboard/statistics.php",
    "content": "<div class=\"podlove-dashboard-statistics-wrapper\">\n\n\t<h4><?php _e('Episodes', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t<table cellspacing=\"0\" cellpadding=\"0\" class=\"podlove-dashboard-statistics\">\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<a href=\"<?php echo $episode_edit_url; ?>&amp;post_status=publish\"><?php echo $statistics['episodes']['publish']; ?></a>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<span style=\"color: #2c6e36;\"><?php _e('Published', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<a href=\"<?php echo $episode_edit_url; ?>&amp;post_status=private\"><?php echo $statistics['episodes']['private']; ?></a>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<span style=\"color: #b43f56;\"><?php _e('Private', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<a href=\"<?php echo $episode_edit_url; ?>&amp;post_status=future\"><?php echo $statistics['episodes']['future']; ?></a>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<span style=\"color: #a8a8a8;\"><?php _e('To be published', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<a href=\"<?php echo $episode_edit_url; ?>&amp;post_status=draft\"><?php echo $statistics['episodes']['draft']; ?></a>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<span style=\"color: #c0844c;\"><?php _e('Drafts', 'podlove-podcasting-plugin-for-wordpress'); ?></span>\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column podlove-dashboard-total-number\">\n\t\t\t\t<a href=\"<?php echo $episode_edit_url; ?>\"><?php echo $statistics['total_number_of_episodes']; ?></a>\n\t\t\t</td>\n\t\t\t<td class=\"podlove-dashboard-total-number\">\n\t\t\t\t<?php _e('Total', 'podlove-podcasting-plugin-for-wordpress'); ?>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n</div>\n<div class=\"podlove-dashboard-statistics-wrapper\">\n\t<h4><?php _e('Statistics', 'podlove-podcasting-plugin-for-wordpress'); ?></h4>\n\t<table cellspacing=\"0\" cellpadding=\"0\" class=\"podlove-dashboard-statistics\">\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php echo gmdate('H:i:s', $statistics['average_episode_length']); ?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php _e('is the average length of an episode', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php\n                $days = round($statistics['total_episode_length'] / 3600 / 24, 1);\n\techo sprintf(_n('%s day', '%s days', $days, 'podlove-podcasting-plugin-for-wordpress'), $days);\n\t?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php _e('is the total playback time of all episodes', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php echo \\Podlove\\format_bytes($statistics['average_media_file_size'], 1); ?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php _e('is the average media file size', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php echo \\Podlove\\format_bytes($statistics['total_media_file_size'], 1); ?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php _e('is the total media file size', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"podlove-dashboard-number-column\">\n\t\t\t\t<?php echo sprintf(_n('%s day', '%s days', $statistics['days_between_releases'], 'podlove-podcasting-plugin-for-wordpress'), $statistics['days_between_releases']); ?>\n\t\t\t</td>\n\t\t\t<td>\n\t\t\t\t<?php _e('is the average interval until a new episode is released', 'podlove-podcasting-plugin-for-wordpress'); ?>.\n\t\t\t</td>\n\t\t</tr>\n\t\t<?php do_action('podlove_dashboard_statistics'); ?>\n\t</table>\n</div>\n<p>\n\t<?php echo sprintf(__('You are using %s', 'podlove-podcasting-plugin-for-wordpress'), '<strong>Podlove Publisher '.\\Podlove\\get_plugin_header('Version').'</strong>'); ?>.\n</p>"
  }
]